pub mod commands;
use crate::config::OrchestratorConfig;
use crate::vcs::{
VcsBackend, VcsError, VcsResult, VcsWarning, Workspace, WorkspaceInfo, WorkspaceManager,
WorkspaceStatus,
};
use async_trait::async_trait;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct GitWorkspace {
pub name: String,
pub path: PathBuf,
pub change_id: String,
pub base_revision: String,
pub status: WorkspaceStatus,
}
impl From<GitWorkspace> for Workspace {
fn from(ws: GitWorkspace) -> Self {
Workspace {
name: ws.name,
path: ws.path,
change_id: ws.change_id,
base_revision: ws.base_revision,
status: ws.status,
}
}
}
pub struct GitWorkspaceManager {
base_dir: PathBuf,
repo_root: PathBuf,
workspaces: Vec<GitWorkspace>,
max_concurrent: usize,
original_branch: Mutex<Option<String>>,
}
impl GitWorkspaceManager {
pub fn new(
base_dir: PathBuf,
repo_root: PathBuf,
max_concurrent: usize,
_config: OrchestratorConfig,
) -> Self {
Self {
base_dir,
repo_root,
workspaces: Vec::new(),
max_concurrent,
original_branch: Mutex::new(None),
}
}
#[allow(dead_code)]
pub fn git_workspaces(&self) -> &[GitWorkspace] {
&self.workspaces
}
#[allow(dead_code)]
pub async fn check_git_available(&self) -> VcsResult<bool> {
commands::check_git_repo(&self.repo_root).await
}
pub async fn check_clean_working_directory(&self) -> VcsResult<Option<VcsWarning>> {
let (has_changes, status) = commands::has_uncommitted_changes(&self.repo_root).await?;
if has_changes {
let warning_msg = format!(
"Warning: Uncommitted changes detected.\n\
Parallel mode will continue, but uncommitted changes remain in your working directory.\n\
Consider committing or stashing if you need isolated workspaces.\n\n\
The following files have uncommitted changes:\n{}",
if status.trim().is_empty() {
" (none listed)".to_string()
} else {
format!("\n{}", status)
}
);
Ok(Some(VcsWarning {
title: "Uncommitted Changes Detected".to_string(),
message: warning_msg,
}))
} else {
Ok(None)
}
}
pub async fn get_current_commit(&self) -> VcsResult<String> {
commands::get_current_commit(&self.repo_root).await
}
pub async fn ensure_original_branch(&self) -> VcsResult<String> {
let mut branch_guard = self.original_branch.lock().await;
if branch_guard.is_none() {
*branch_guard = match commands::get_current_branch(&self.repo_root).await? {
Some(branch) => Some(branch),
None => return Err(VcsError::git_command(
"Detached HEAD state detected. Checkout a branch before running parallel mode.",
)),
};
}
branch_guard
.clone()
.ok_or_else(|| VcsError::git_command("Original branch not initialized"))
}
pub async fn create_worktree(
&mut self,
change_id: &str,
base_commit: Option<&str>,
) -> VcsResult<GitWorkspace> {
let _ = self.ensure_original_branch().await?;
let branch_name = change_id.replace(['/', '\\', ' '], "-");
let worktree_path = self.base_dir.join(&branch_name);
if !self.base_dir.exists() {
std::fs::create_dir_all(&self.base_dir)?;
}
let base = match base_commit {
Some(commit) => commit.to_string(),
None => self.get_current_commit().await?,
};
info!(
"Creating worktree '{}' at {:?} from commit {}",
branch_name,
worktree_path,
&base[..8.min(base.len())]
);
commands::worktree_add(
&self.repo_root,
worktree_path.to_str().unwrap(),
&branch_name,
&base,
)
.await?;
commands::run_worktree_setup(&self.repo_root, &worktree_path).await?;
let workspace = GitWorkspace {
name: branch_name,
path: worktree_path,
change_id: change_id.to_string(),
base_revision: base,
status: WorkspaceStatus::Created,
};
self.workspaces.push(workspace.clone());
debug!("Created worktree: {:?}", workspace.name);
Ok(workspace)
}
pub fn update_git_workspace_status(&mut self, workspace_name: &str, status: WorkspaceStatus) {
if let Some(ws) = self
.workspaces
.iter_mut()
.find(|w| w.name == workspace_name)
{
ws.status = status;
}
}
pub async fn merge_branches(&self, branch_names: &[String]) -> VcsResult<String> {
if branch_names.is_empty() {
return Err(VcsError::git_command("No branches to merge"));
}
let _ = self.ensure_original_branch().await?;
let original = self
.original_branch
.lock()
.await
.as_ref()
.ok_or_else(|| VcsError::git_command("Original branch not initialized"))?
.clone();
info!("Checking out original branch '{}' for merge", original);
commands::checkout(&self.repo_root, &original).await?;
for branch_name in branch_names {
info!("Merging branch '{}'", branch_name);
commands::merge(&self.repo_root, branch_name).await?;
}
let final_commit = self.get_current_commit().await?;
info!(
"All branches merged successfully. Final commit: {}",
&final_commit[..8.min(final_commit.len())]
);
Ok(final_commit)
}
pub async fn cleanup_worktree(&mut self, workspace_name: &str) -> VcsResult<()> {
let mut workspace_path = None;
let mut change_id = None;
if let Some(workspace) = self.workspaces.iter().find(|w| w.name == workspace_name) {
workspace_path = Some(workspace.path.clone());
change_id = Some(workspace.change_id.clone());
}
if workspace_path.is_none() {
if let Some(info) = self.find_worktree_by_name(workspace_name).await? {
workspace_path = Some(info.path);
change_id = Some(info.change_id);
}
}
if workspace_path.is_none() {
if let Some(extracted_change_id) =
Self::extract_change_id_from_worktree_name(workspace_name)
{
let candidates = self
.find_all_worktrees_for_change(&extracted_change_id)
.await?;
if let Some(matching) = candidates
.iter()
.find(|candidate| candidate.workspace_name == workspace_name)
{
workspace_path = Some(matching.path.clone());
change_id = Some(matching.change_id.clone());
} else if let Some(newest) = candidates.first() {
warn!(
"Worktree '{}' not found in tracked list; using newest worktree '{}' for change '{}'",
workspace_name, newest.workspace_name, extracted_change_id
);
workspace_path = Some(newest.path.clone());
change_id = Some(newest.change_id.clone());
}
}
}
let Some(worktree_path) = workspace_path else {
warn!("Worktree '{}' not found for cleanup", workspace_name);
return Ok(());
};
info!("Cleaning up worktree '{}'", workspace_name);
if worktree_path.exists() {
commands::worktree_remove_with_options(
&self.repo_root,
worktree_path.to_str().unwrap(),
commands::WorktreeRemoveOptions::default(),
)
.await
.map_err(|e| {
VcsError::git_command(format!(
"Failed to remove worktree '{}' at '{}': {}",
workspace_name,
worktree_path.display(),
e
))
})?;
}
if let Err(e) = commands::branch_delete(&self.repo_root, workspace_name).await {
debug!(
"Failed to delete branch '{}': {} (may have been merged)",
workspace_name, e
);
}
if let Some(change_id) = change_id {
self.update_git_workspace_status(workspace_name, WorkspaceStatus::Cleaned);
debug!(
"Worktree '{}' cleaned up for change '{}'",
workspace_name, change_id
);
} else {
self.update_git_workspace_status(workspace_name, WorkspaceStatus::Cleaned);
debug!("Worktree '{}' cleaned up", workspace_name);
}
Ok(())
}
#[allow(dead_code)]
pub async fn cleanup_all_worktrees(&mut self) -> VcsResult<()> {
let workspace_names: Vec<String> = self.workspaces.iter().map(|w| w.name.clone()).collect();
for name in workspace_names {
let _ = self.cleanup_worktree(&name).await;
}
self.workspaces.clear();
if self.base_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&self.base_dir) {
if entries.count() == 0 {
let _ = std::fs::remove_dir(&self.base_dir);
}
}
}
Ok(())
}
pub(crate) fn extract_change_id_from_worktree_name(worktree_name: &str) -> Option<String> {
if worktree_name.starts_with("oso-session-") {
return None;
}
if let Some(without_prefix) = worktree_name.strip_prefix("ws-") {
if let Some(last_dash_pos) = without_prefix.rfind('-') {
let potential_suffix = &without_prefix[last_dash_pos + 1..];
if potential_suffix.len() >= 7
&& potential_suffix.chars().all(|c| c.is_ascii_hexdigit())
{
return Some(without_prefix[..last_dash_pos].to_string());
}
}
return Some(without_prefix.to_string());
}
Some(worktree_name.to_string())
}
}
pub async fn get_worktree_path_for_change(
repo_root: &Path,
change_id: &str,
) -> VcsResult<Option<PathBuf>> {
let output = commands::run_git(&["worktree", "list", "--porcelain"], repo_root).await?;
let sanitized = change_id.replace(['/', '\\', ' '], "-");
let mut current_path: Option<PathBuf> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(path));
} else if let Some(branch_name) = line.strip_prefix("branch refs/heads/") {
if let Some(extracted_change_id) =
GitWorkspaceManager::extract_change_id_from_worktree_name(branch_name)
{
if extracted_change_id == sanitized {
return Ok(current_path);
}
}
} else if line.is_empty() {
current_path = None;
}
}
Ok(None)
}
pub async fn list_worktree_change_ids(repo_root: &Path) -> VcsResult<HashSet<String>> {
let output = commands::run_git(&["worktree", "list", "--porcelain"], repo_root).await?;
let mut change_ids = HashSet::new();
let mut current_branch: Option<String> = None;
for line in output.lines() {
if let Some(branch_name) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch_name.to_string());
} else if line.is_empty() {
if let Some(branch) = current_branch.take() {
if let Some(change_id) =
GitWorkspaceManager::extract_change_id_from_worktree_name(&branch)
{
change_ids.insert(change_id);
}
}
}
}
if let Some(branch) = current_branch {
if let Some(change_id) = GitWorkspaceManager::extract_change_id_from_worktree_name(&branch)
{
change_ids.insert(change_id);
}
}
Ok(change_ids)
}
impl GitWorkspaceManager {
async fn find_worktree_by_name(
&self,
workspace_name: &str,
) -> VcsResult<Option<WorkspaceInfo>> {
let output =
commands::run_git(&["worktree", "list", "--porcelain"], &self.repo_root).await?;
let mut current_worktree_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
for line in output.lines() {
if let Some(worktree_path) = line.strip_prefix("worktree ") {
current_worktree_path = Some(PathBuf::from(worktree_path));
} else if let Some(branch_name) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch_name.to_string());
} else if line.is_empty() {
if let (Some(path), Some(branch)) = (¤t_worktree_path, ¤t_branch) {
if branch == workspace_name {
let last_modified = if path.exists() {
path.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
} else {
SystemTime::UNIX_EPOCH
};
let change_id = Self::extract_change_id_from_worktree_name(branch)
.unwrap_or_else(|| workspace_name.to_string());
return Ok(Some(WorkspaceInfo {
path: path.clone(),
change_id,
workspace_name: branch.clone(),
last_modified,
}));
}
}
current_worktree_path = None;
current_branch = None;
}
}
if let (Some(path), Some(branch)) = (¤t_worktree_path, ¤t_branch) {
if branch == workspace_name {
let last_modified = if path.exists() {
path.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
} else {
SystemTime::UNIX_EPOCH
};
let change_id = Self::extract_change_id_from_worktree_name(branch)
.unwrap_or_else(|| workspace_name.to_string());
return Ok(Some(WorkspaceInfo {
path: path.clone(),
change_id,
workspace_name: branch.clone(),
last_modified,
}));
}
}
Ok(None)
}
async fn find_all_worktrees_for_change(
&self,
change_id: &str,
) -> VcsResult<Vec<WorkspaceInfo>> {
let output =
commands::run_git(&["worktree", "list", "--porcelain"], &self.repo_root).await?;
let sanitized_change_id = change_id.replace(['/', '\\', ' '], "-");
let mut candidates = Vec::new();
let mut current_worktree_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
for line in output.lines() {
if let Some(worktree_path) = line.strip_prefix("worktree ") {
current_worktree_path = Some(PathBuf::from(worktree_path));
} else if let Some(branch_name) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch_name.to_string());
} else if line.is_empty() {
if let (Some(path), Some(branch)) = (¤t_worktree_path, ¤t_branch) {
if let Some(extracted_change_id) =
Self::extract_change_id_from_worktree_name(branch)
{
if extracted_change_id == sanitized_change_id {
let last_modified = if path.exists() {
path.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
} else {
SystemTime::UNIX_EPOCH
};
candidates.push(WorkspaceInfo {
path: path.clone(),
change_id: change_id.to_string(),
workspace_name: branch.clone(),
last_modified,
});
}
}
}
current_worktree_path = None;
current_branch = None;
}
}
if let (Some(path), Some(branch)) = (¤t_worktree_path, ¤t_branch) {
if let Some(extracted_change_id) = Self::extract_change_id_from_worktree_name(branch) {
if extracted_change_id == sanitized_change_id {
let last_modified = if path.exists() {
path.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
} else {
SystemTime::UNIX_EPOCH
};
candidates.push(WorkspaceInfo {
path: path.clone(),
change_id: change_id.to_string(),
workspace_name: branch.clone(),
last_modified,
});
}
}
}
candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.last_modified));
Ok(candidates)
}
async fn validate_worktree_consistency(
&self,
workspace_info: &WorkspaceInfo,
expected_branch: &str,
) -> VcsResult<bool> {
if !workspace_info.path.exists() {
info!(
"Worktree path {:?} does not exist, not safe to resume",
workspace_info.path
);
return Ok(false);
}
let current_branch = commands::get_current_branch(&workspace_info.path).await?;
if current_branch.as_deref() != Some(expected_branch) {
info!(
"Worktree '{}' has branch {:?}, expected '{}', not safe to resume",
workspace_info.workspace_name, current_branch, expected_branch
);
return Ok(false);
}
if !commands::branch_exists(&self.repo_root, expected_branch).await? {
info!(
"Branch 'refs/heads/{}' does not exist in repository, not safe to resume",
expected_branch
);
return Ok(false);
}
Ok(true)
}
async fn cleanup_inconsistent_worktree(&self, workspace_info: &WorkspaceInfo) -> VcsResult<()> {
info!(
"Cleaning up inconsistent worktree '{}' at {:?}",
workspace_info.workspace_name, workspace_info.path
);
if workspace_info.path.exists() {
commands::worktree_remove_with_options(
&self.repo_root,
workspace_info.path.to_str().unwrap(),
commands::WorktreeRemoveOptions::default(),
)
.await
.map_err(|e| {
VcsError::git_command(format!(
"Failed to remove inconsistent worktree '{}' at '{}': {}",
workspace_info.workspace_name,
workspace_info.path.display(),
e
))
})?;
}
if let Err(e) =
commands::branch_delete(&self.repo_root, &workspace_info.workspace_name).await
{
debug!(
"Failed to delete branch '{}': {} (may already be deleted)",
workspace_info.workspace_name, e
);
}
Ok(())
}
}
#[async_trait]
impl WorkspaceManager for GitWorkspaceManager {
fn backend_type(&self) -> VcsBackend {
VcsBackend::Git
}
async fn check_available(&self) -> VcsResult<bool> {
self.check_git_available().await
}
async fn prepare_for_parallel(&self) -> VcsResult<Option<VcsWarning>> {
self.ensure_original_branch().await?;
self.check_clean_working_directory().await
}
async fn get_current_revision(&self) -> VcsResult<String> {
self.get_current_commit().await
}
async fn create_workspace(
&mut self,
change_id: &str,
base_revision: Option<&str>,
) -> VcsResult<Workspace> {
let git_ws = self.create_worktree(change_id, base_revision).await?;
Ok(git_ws.into())
}
fn update_workspace_status(&mut self, workspace_name: &str, status: WorkspaceStatus) {
self.update_git_workspace_status(workspace_name, status);
}
async fn merge_workspaces(&self, revisions: &[String]) -> VcsResult<String> {
self.merge_branches(revisions).await
}
async fn cleanup_workspace(&mut self, workspace_name: &str) -> VcsResult<()> {
self.cleanup_worktree(workspace_name).await
}
async fn cleanup_all(&mut self) -> VcsResult<()> {
self.cleanup_all_worktrees().await
}
fn max_concurrent(&self) -> usize {
self.max_concurrent
}
fn workspaces(&self) -> Vec<Workspace> {
self.workspaces
.iter()
.map(|w| Workspace {
name: w.name.clone(),
path: w.path.clone(),
change_id: w.change_id.clone(),
base_revision: w.base_revision.clone(),
status: w.status.clone(),
})
.collect()
}
async fn list_worktree_change_ids(&self) -> VcsResult<HashSet<String>> {
list_worktree_change_ids(&self.repo_root).await
}
fn conflict_resolution_prompt(&self) -> &'static str {
"This project uses Git for version control, not jj.\n\n\
A merge conflict occurred. The conflicting files contain Git conflict markers:\n\
<<<<<<< HEAD\n\
[your changes]\n\
=======\n\
[incoming changes]\n\
>>>>>>> [branch]\n\n\
Please resolve the conflicts by:\n\
1. Editing the conflicting files to remove conflict markers\n\
2. Choosing the correct content for each conflict\n\
3. Running `git add <file>` for each resolved file\n\
4. Running `git commit` to complete the merge"
}
async fn snapshot_working_copy(&self, _workspace_path: &Path) -> VcsResult<()> {
Ok(())
}
async fn set_commit_message(&self, workspace_path: &Path, message: &str) -> VcsResult<()> {
if commands::has_changes_to_commit(workspace_path).await? {
commands::add_and_commit(workspace_path, message).await?;
} else {
let result =
commands::run_git(&["commit", "--amend", "-m", message], workspace_path).await;
if let Err(e) = result {
warn!("Failed to amend commit message: {}", e);
}
}
Ok(())
}
async fn create_iteration_snapshot(
&self,
workspace_path: &Path,
change_id: &str,
iteration: u32,
completed: u32,
total: u32,
) -> VcsResult<()> {
let wip_message = format!(
"WIP: {} ({}/{} tasks, apply#{})",
change_id, completed, total, iteration
);
debug!(
"Creating iteration snapshot #{} for {}",
iteration, change_id
);
commands::run_git(&["add", "-A"], workspace_path).await?;
let result = commands::run_git(
&["commit", "--no-verify", "--allow-empty", "-m", &wip_message],
workspace_path,
)
.await;
if let Err(e) = result {
warn!(
"Failed to create WIP commit for iteration {}: {}",
iteration, e
);
} else {
debug!(
"Iteration snapshot #{} created for {}",
iteration, change_id
);
}
Ok(())
}
async fn squash_wip_commits(
&self,
workspace_path: &Path,
change_id: &str,
final_iteration: u32,
) -> VcsResult<()> {
let apply_message = format!("Apply: {} (apply#{})", change_id, final_iteration);
debug!("Squashing WIP commits for {} into Apply commit", change_id);
let wip_pattern = format!("^WIP: {} ", change_id);
let wip_commits = commands::run_git(
&["rev-list", "--reverse", "--grep", &wip_pattern, "HEAD"],
workspace_path,
)
.await?;
let first_wip = wip_commits
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| {
VcsError::git_command(format!("No WIP commits found for {}", change_id))
})?;
let parent_revision =
commands::run_git(&["rev-parse", &format!("{}^", first_wip)], workspace_path).await?;
let parent_revision = parent_revision.trim();
commands::run_git(&["reset", "--soft", parent_revision], workspace_path).await?;
commands::run_git(
&["commit", "--allow-empty", "-m", &apply_message],
workspace_path,
)
.await?;
info!("WIP commits squashed into Apply commit for {}", change_id);
Ok(())
}
async fn get_revision_in_workspace(&self, workspace_path: &Path) -> VcsResult<String> {
commands::get_current_commit(workspace_path).await
}
async fn get_status(&self) -> VcsResult<String> {
commands::get_status(&self.repo_root).await
}
async fn get_log_for_revisions(&self, revisions: &[String]) -> VcsResult<String> {
if revisions.is_empty() {
return Ok(String::new());
}
let mut logs = Vec::new();
for rev in revisions {
let log = commands::run_git(&["log", "-1", "--oneline", rev], &self.repo_root).await?;
logs.push(log);
}
Ok(logs.join("\n"))
}
async fn detect_conflicts(&self) -> VcsResult<Vec<String>> {
commands::get_conflict_files(&self.repo_root).await
}
fn forget_workspace_sync(&self, workspace_name: &str) {
debug!(
"Emergency cleanup: removing git worktree for '{}'",
workspace_name
);
if let Some(workspace) = self.workspaces.iter().find(|w| w.name == workspace_name) {
let path = workspace.path.to_str().unwrap_or("");
debug!(
module = module_path!(),
"Executing git command: git worktree remove {} --force (cwd: {:?})",
path,
self.repo_root
);
let result = std::process::Command::new("git")
.args(["worktree", "remove", path, "--force"])
.current_dir(&self.repo_root)
.output();
match result {
Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("Failed to remove worktree '{}': {}", workspace_name, stderr);
if workspace.path.exists() {
let _ = std::fs::remove_dir_all(&workspace.path);
}
}
Err(e) => {
debug!("Failed to run git worktree remove: {}", e);
if workspace.path.exists() {
let _ = std::fs::remove_dir_all(&workspace.path);
}
}
_ => {
debug!("Successfully removed worktree '{}'", workspace_name);
}
}
debug!(
module = module_path!(),
"Executing git command: git branch -D {} (cwd: {:?})",
workspace_name,
self.repo_root
);
let _ = std::process::Command::new("git")
.args(["branch", "-D", workspace_name])
.current_dir(&self.repo_root)
.output();
}
}
fn repo_root(&self) -> &Path {
&self.repo_root
}
async fn ensure_original_branch_initialized(&self) -> VcsResult<String> {
self.ensure_original_branch().await
}
fn original_branch(&self) -> Option<String> {
self.original_branch
.try_lock()
.ok()
.and_then(|guard| guard.clone())
}
async fn find_existing_workspace(
&mut self,
change_id: &str,
) -> VcsResult<Option<WorkspaceInfo>> {
let mut candidates = self.find_all_worktrees_for_change(change_id).await?;
if candidates.is_empty() {
return Ok(None);
}
let expected_branch = change_id.replace(['/', '\\', ' '], "-");
let newest = candidates.remove(0);
let is_consistent = self
.validate_worktree_consistency(&newest, &expected_branch)
.await?;
if !is_consistent {
info!(
"Newest worktree '{}' is inconsistent, cleaning up and creating new",
newest.workspace_name
);
self.cleanup_inconsistent_worktree(&newest).await?;
for old_ws in candidates {
self.cleanup_inconsistent_worktree(&old_ws).await?;
}
return Ok(None);
}
for old_ws in candidates {
info!(
"Cleaning up older worktree '{}' for change '{}'",
old_ws.workspace_name, change_id
);
self.cleanup_inconsistent_worktree(&old_ws).await?;
}
debug!(
"Found consistent worktree '{}' for change '{}' (last modified: {:?})",
newest.workspace_name, change_id, newest.last_modified
);
Ok(Some(newest))
}
async fn reuse_workspace(&mut self, workspace_info: &WorkspaceInfo) -> VcsResult<Workspace> {
let _ = self.ensure_original_branch().await?;
info!(
"Reusing existing worktree '{}' at {:?}",
workspace_info.workspace_name, workspace_info.path
);
let base_revision = if workspace_info.path.exists() {
commands::get_current_commit(&workspace_info.path)
.await
.unwrap_or_else(|_| "unknown".to_string())
} else {
"unknown".to_string()
};
let workspace = GitWorkspace {
name: workspace_info.workspace_name.clone(),
path: workspace_info.path.clone(),
change_id: workspace_info.change_id.clone(),
base_revision,
status: WorkspaceStatus::Created,
};
self.workspaces.push(workspace.clone());
Ok(Workspace {
name: workspace.name,
path: workspace.path,
change_id: workspace.change_id,
base_revision: workspace.base_revision,
status: workspace.status,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::process::Command;
use tracing::debug;
fn create_test_manager() -> (GitWorkspaceManager, TempDir) {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path().join("worktrees");
let repo_root = temp_dir.path().to_path_buf();
let config = OrchestratorConfig::default();
let manager = GitWorkspaceManager::new(base_dir, repo_root, 3, config);
(manager, temp_dir)
}
#[test]
fn test_manager_creation() {
let (manager, _temp) = create_test_manager();
assert_eq!(manager.max_concurrent, 3);
assert!(manager.workspaces.is_empty());
}
#[tokio::test]
async fn test_check_clean_working_directory_warns_when_dirty() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path().join("worktrees");
let repo_root = temp_dir.path().to_path_buf();
debug!(
module = module_path!(),
"Executing git command: git init (cwd: {:?})",
temp_dir.path()
);
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return;
}
std::fs::write(temp_dir.path().join("dirty.txt"), "content").unwrap();
let manager =
GitWorkspaceManager::new(base_dir, repo_root, 3, OrchestratorConfig::default());
let warning = manager.check_clean_working_directory().await.unwrap();
assert!(warning.is_some());
let warning = warning.unwrap();
assert!(warning
.message
.contains("Warning: Uncommitted changes detected."));
assert!(warning.message.contains("Parallel mode will continue"));
assert_eq!(warning.title, "Uncommitted Changes Detected");
}
#[test]
fn test_workspace_name_sanitization() {
let change_id = "feature/add-login";
let sanitized = change_id.replace(['/', '\\', ' '], "-");
assert_eq!(sanitized, "feature-add-login");
}
#[test]
fn test_backend_type() {
let (manager, _temp) = create_test_manager();
assert_eq!(manager.backend_type(), VcsBackend::Git);
}
#[test]
fn test_extract_change_id_from_worktree_name_standard() {
let result =
GitWorkspaceManager::extract_change_id_from_worktree_name("ws-my-change-1234abcd");
assert_eq!(result, Some("my-change".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_with_dashes() {
let result =
GitWorkspaceManager::extract_change_id_from_worktree_name("ws-add-user-auth-abcdef12");
assert_eq!(result, Some("add-user-auth".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_no_suffix() {
let result = GitWorkspaceManager::extract_change_id_from_worktree_name("ws-my-change");
assert_eq!(result, Some("my-change".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_not_matching_prefix() {
let result = GitWorkspaceManager::extract_change_id_from_worktree_name("main");
assert_eq!(result, Some("main".to_string()));
let result2 = GitWorkspaceManager::extract_change_id_from_worktree_name("feature-test");
assert_eq!(result2, Some("feature-test".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_short_suffix() {
let result = GitWorkspaceManager::extract_change_id_from_worktree_name("ws-change-abc");
assert_eq!(result, Some("change-abc".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_non_hex_suffix() {
let result =
GitWorkspaceManager::extract_change_id_from_worktree_name("ws-change-notahex!");
assert_eq!(result, Some("change-notahex!".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_path_chars() {
let result =
GitWorkspaceManager::extract_change_id_from_worktree_name("ws-feature-login-fedcba98");
assert_eq!(result, Some("feature-login".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_new_format() {
let result = GitWorkspaceManager::extract_change_id_from_worktree_name("my-change");
assert_eq!(result, Some("my-change".to_string()));
let result2 = GitWorkspaceManager::extract_change_id_from_worktree_name("add-user-auth");
assert_eq!(result2, Some("add-user-auth".to_string()));
}
#[test]
fn test_extract_change_id_from_worktree_name_tui_session() {
let result =
GitWorkspaceManager::extract_change_id_from_worktree_name("oso-session-abc123");
assert_eq!(result, None);
let result2 =
GitWorkspaceManager::extract_change_id_from_worktree_name("oso-session-f4d3a2");
assert_eq!(result2, None);
}
#[tokio::test]
async fn test_parallel_worktree_branch_naming() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path().join("worktrees");
let repo_root = temp_dir.path().to_path_buf();
let init_result = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let mut manager =
GitWorkspaceManager::new(base_dir, repo_root, 3, OrchestratorConfig::default());
let result = manager.create_worktree("my-change", None).await;
assert!(result.is_ok());
let workspace = result.unwrap();
assert_eq!(workspace.name, "my-change");
assert!(workspace.path.exists());
let branch_check = Command::new("git")
.args(["show-ref", "--verify", "refs/heads/my-change"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
assert!(branch_check.status.success());
let branch_name = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&workspace.path)
.output()
.await
.unwrap();
let branch_output = String::from_utf8_lossy(&branch_name.stdout);
assert_eq!(branch_output.trim(), "my-change");
}
#[tokio::test]
async fn test_resume_validation_inconsistent_branch() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path().join("worktrees");
let repo_root = temp_dir.path().to_path_buf();
let init_result = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let mut manager =
GitWorkspaceManager::new(base_dir, repo_root, 3, OrchestratorConfig::default());
let result = manager.create_worktree("test-change", None).await;
assert!(result.is_ok());
let workspace = result.unwrap();
let _ = Command::new("git")
.args(["checkout", "-b", "wrong-branch"])
.current_dir(&workspace.path)
.output()
.await;
manager.workspaces.clear();
let found = manager.find_existing_workspace("test-change").await;
assert!(found.is_ok());
assert!(found.unwrap().is_none());
assert!(workspace.path.exists());
let _ = Command::new("git")
.args([
"worktree",
"remove",
workspace.path.to_str().unwrap(),
"--force",
])
.current_dir(temp_dir.path())
.output()
.await;
}
#[tokio::test]
async fn test_get_worktree_path_for_change() {
let temp_dir = tempfile::TempDir::new().unwrap();
let repo_path = temp_dir.path();
Command::new("git")
.args(["init"])
.current_dir(repo_path)
.output()
.await
.unwrap();
std::fs::write(repo_path.join("test.txt"), "test").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo_path)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(repo_path)
.output()
.await
.unwrap();
let worktree_path = temp_dir.path().join("my-change-worktree");
Command::new("git")
.args([
"worktree",
"add",
worktree_path.to_str().unwrap(),
"-b",
"my-change",
"HEAD",
])
.current_dir(repo_path)
.output()
.await
.unwrap();
let result = get_worktree_path_for_change(repo_path, "my-change").await;
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.is_some());
assert!(path.unwrap().ends_with("my-change-worktree"));
let result = get_worktree_path_for_change(repo_path, "nonexistent").await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}