use std::path::Path;
use tokio::process::Command;
use tracing::debug;
use crate::error::{OrchestratorError, Result};
use crate::execution::archive::is_archive_commit_complete;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceState {
Created,
Applying { iteration: u32 },
Blocked,
Rejecting,
Applied,
Archiving,
Archived,
Merged,
}
pub async fn is_merged_to_base(
change_id: &str,
repo_root: &Path,
base_branch: &str,
) -> Result<bool> {
let rev_parse_output = Command::new("git")
.args(["rev-parse", "--verify", base_branch])
.current_dir(repo_root)
.output()
.await
.map_err(|e| {
OrchestratorError::GitCommand(format!("Failed to verify base branch: {}", e))
})?;
if !rev_parse_output.status.success() {
debug!(
base_branch = %base_branch,
"is_merged_to_base: base branch does not exist"
);
return Ok(false);
}
let archive_path = format!("{}:openspec/changes/archive/", base_branch);
let ls_tree_output = Command::new("git")
.args(["ls-tree", "-d", &archive_path])
.current_dir(repo_root)
.output()
.await
.map_err(|e| {
OrchestratorError::GitCommand(format!("Failed to list archive tree: {}", e))
})?;
if !ls_tree_output.status.success() {
debug!(
base_branch = %base_branch,
"is_merged_to_base: archive directory does not exist in base branch"
);
return Ok(false);
}
let output = String::from_utf8_lossy(&ls_tree_output.stdout);
let archive_entry_exists = output.lines().any(|line| {
if let Some(name) = line.split('\t').nth(1) {
name == change_id || name.ends_with(&format!("-{}", change_id))
} else {
false
}
});
let change_path = format!("{}:openspec/changes/{}", base_branch, change_id);
let change_exists_output = Command::new("git")
.args(["ls-tree", "-d", &change_path])
.current_dir(repo_root)
.output()
.await
.map_err(|e| {
OrchestratorError::GitCommand(format!("Failed to check change tree: {}", e))
})?;
let change_dir_exists = change_exists_output.status.success()
&& !String::from_utf8_lossy(&change_exists_output.stdout)
.trim()
.is_empty();
debug!(
change_id = %change_id,
base_branch = %base_branch,
archive_entry_exists = archive_entry_exists,
change_dir_exists = change_dir_exists,
"is_merged_to_base: checking base branch HEAD tree file state"
);
if archive_entry_exists && change_dir_exists {
tracing::warn!(
change_id = %change_id,
base_branch = %base_branch,
"Archive entry found in base branch but change directory still exists in base branch tree"
);
return Ok(false);
}
Ok(archive_entry_exists && !change_dir_exists)
}
pub async fn get_latest_wip_snapshot(change_id: &str, repo_root: &Path) -> Result<Option<u32>> {
let wip_prefix = format!("WIP(apply): {}", change_id);
let log_output = Command::new("git")
.args(["log", "--format=%s", "--grep", &wip_prefix, "--all-match"])
.current_dir(repo_root)
.output()
.await
.map_err(|e| OrchestratorError::GitCommand(format!("Failed to read git log: {}", e)))?;
if !log_output.status.success() {
let stderr = String::from_utf8_lossy(&log_output.stderr);
return Err(OrchestratorError::GitCommand(format!(
"Failed to read git log: {}",
stderr
)));
}
let commits = String::from_utf8_lossy(&log_output.stdout);
let mut max_iteration = None;
for line in commits.lines() {
if let Some(iteration_part) = line.strip_prefix(&wip_prefix) {
if let Some(captures) = iteration_part
.trim()
.strip_prefix("(iteration ")
.and_then(|s| s.split_once('/'))
{
if let Ok(iteration) = captures.0.trim().parse::<u32>() {
max_iteration =
Some(max_iteration.map_or(iteration, |m: u32| m.max(iteration)));
}
}
}
}
debug!(
change_id = %change_id,
max_iteration = ?max_iteration,
"get_latest_wip_snapshot: found WIP commits"
);
Ok(max_iteration)
}
pub async fn has_archive_files(change_id: &str, repo_root: &Path) -> Result<bool> {
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo_root)
.output()
.await
.map_err(|e| OrchestratorError::GitCommand(format!("Failed to check git status: {}", e)))?;
if !status_output.status.success() {
let stderr = String::from_utf8_lossy(&status_output.stderr);
return Err(OrchestratorError::GitCommand(format!(
"Failed to check git status: {}",
stderr
)));
}
let is_dirty = !String::from_utf8_lossy(&status_output.stdout)
.trim()
.is_empty();
let change_path = repo_root.join("openspec/changes").join(change_id);
let change_exists = change_path.exists();
let archive_base = repo_root.join("openspec/changes/archive");
let mut archive_entry_exists = false;
if archive_base.exists() {
let exact_match = archive_base.join(change_id);
if exact_match.exists() && exact_match.is_dir() {
archive_entry_exists = true;
} else {
if let Ok(entries) = std::fs::read_dir(&archive_base) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(&format!("-{}", change_id)) && entry.path().is_dir() {
archive_entry_exists = true;
break;
}
}
}
}
}
debug!(
change_id = %change_id,
is_dirty = is_dirty,
change_exists = change_exists,
archive_entry_exists = archive_entry_exists,
"has_archive_files: checking archiving state (dirty={}, change_gone={}, archive_exists={})",
is_dirty,
!change_exists,
archive_entry_exists
);
Ok(is_dirty && !change_exists && archive_entry_exists)
}
pub async fn has_apply_commit(change_id: &str, repo_root: &Path) -> Result<bool> {
let expected_subject = format!("Apply: {}", change_id);
let log_output = Command::new("git")
.args([
"log",
"--format=%s",
"--grep",
&expected_subject,
"--all-match",
])
.current_dir(repo_root)
.output()
.await
.map_err(|e| OrchestratorError::GitCommand(format!("Failed to read git log: {}", e)))?;
if !log_output.status.success() {
let stderr = String::from_utf8_lossy(&log_output.stderr);
return Err(OrchestratorError::GitCommand(format!(
"Failed to read git log: {}",
stderr
)));
}
let commits = String::from_utf8_lossy(&log_output.stdout);
let found = commits.lines().any(|line| line.trim() == expected_subject);
debug!(
change_id = %change_id,
expected_subject = %expected_subject,
found = found,
"has_apply_commit: checking apply commit"
);
Ok(found)
}
pub async fn detect_workspace_state(
change_id: &str,
repo_root: &Path,
base_branch: &str,
) -> Result<WorkspaceState> {
if is_merged_to_base(change_id, repo_root, base_branch).await? {
debug!(change_id = %change_id, "State: Merged");
return Ok(WorkspaceState::Merged);
}
if is_archive_commit_complete(change_id, Some(repo_root)).await? {
debug!(change_id = %change_id, "State: Archived");
return Ok(WorkspaceState::Archived);
}
if has_archive_files(change_id, repo_root).await? {
debug!(change_id = %change_id, "State: Archiving (files moved, commit incomplete)");
return Ok(WorkspaceState::Archiving);
}
let blocked_marker_path = repo_root
.join("openspec")
.join("changes")
.join(change_id)
.join("APPLY_BLOCKED")
.join("marker.md");
if blocked_marker_path.exists() {
debug!(
change_id = %change_id,
blocked_marker_path = %blocked_marker_path.display(),
"State: Blocked"
);
return Ok(WorkspaceState::Blocked);
}
let rejected_path = repo_root
.join("openspec")
.join("changes")
.join(change_id)
.join("REJECTED.md");
if rejected_path.exists() {
debug!(
change_id = %change_id,
rejected_path = %rejected_path.display(),
"State: Rejecting"
);
return Ok(WorkspaceState::Rejecting);
}
if has_apply_commit(change_id, repo_root).await? {
debug!(change_id = %change_id, "State: Applied");
return Ok(WorkspaceState::Applied);
}
if let Some(iteration) = get_latest_wip_snapshot(change_id, repo_root).await? {
debug!(change_id = %change_id, iteration = iteration, "State: Applying");
return Ok(WorkspaceState::Applying {
iteration: iteration + 1,
});
}
debug!(change_id = %change_id, "State: Created");
Ok(WorkspaceState::Created)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command as StdCommand;
use tempfile::TempDir;
fn init_git_repo(repo_root: &Path) {
StdCommand::new("git")
.args(["init", "-b", "main"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo_root)
.output()
.unwrap();
}
fn commit(repo_root: &Path, message: &str) {
fs::write(repo_root.join("test.txt"), message).unwrap();
StdCommand::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", message])
.current_dir(repo_root)
.output()
.unwrap();
}
#[tokio::test]
async fn test_detect_workspace_state_created() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Created);
}
#[tokio::test]
async fn test_detect_workspace_state_applying() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
commit(repo_root, "WIP(apply): test-change (iteration 1/5)");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Applying { iteration: 2 });
}
#[tokio::test]
async fn test_detect_workspace_state_applied() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
commit(repo_root, "Apply: test-change");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Applied);
}
#[tokio::test]
async fn test_detect_workspace_state_archived() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-test-change"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/test-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
commit(repo_root, "Archive: test-change");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Archived);
}
#[tokio::test]
async fn test_detect_workspace_state_merged() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let archive_dir = repo_root.join("openspec/changes/archive/test-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
commit(repo_root, "Archive: test-change");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Merged);
}
#[tokio::test]
async fn test_detect_workspace_state_merged_with_remaining_changes() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let changes_dir = repo_root.join("openspec/changes/test-change");
fs::create_dir_all(&changes_dir).unwrap();
fs::write(changes_dir.join("proposal.md"), "# Test Change").unwrap();
commit(repo_root, "Archive: test-change");
let state = detect_workspace_state("test-change", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Created);
}
#[tokio::test]
async fn test_get_latest_wip_snapshot_multiple() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
commit(repo_root, "WIP(apply): test-change (iteration 1/5)");
commit(repo_root, "WIP(apply): test-change (iteration 2/5)");
commit(repo_root, "WIP(apply): test-change (iteration 3/5)");
let iteration = get_latest_wip_snapshot("test-change", repo_root)
.await
.unwrap();
assert_eq!(iteration, Some(3));
}
#[tokio::test]
async fn test_get_latest_wip_snapshot_none() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let iteration = get_latest_wip_snapshot("test-change", repo_root)
.await
.unwrap();
assert_eq!(iteration, None);
}
#[tokio::test]
async fn test_has_apply_commit_true() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
commit(repo_root, "Apply: test-change");
let result = has_apply_commit("test-change", repo_root).await.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_has_apply_commit_false() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let result = has_apply_commit("test-change", repo_root).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_is_merged_to_base_true() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let archive_dir = repo_root.join("openspec/changes/archive/test-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
commit(repo_root, "Archive: test-change");
let result = is_merged_to_base("test-change", repo_root, "main")
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_is_merged_to_base_false_on_branch() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "feature-branch"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/test-change");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
commit(repo_root, "Archive: test-change");
let result = is_merged_to_base("test-change", repo_root, "main")
.await
.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_has_archive_files_exact_match() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let archive_dir = repo_root.join("openspec/changes/archive/test-archiving");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
let result = has_archive_files("test-archiving", repo_root)
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_has_archive_files_date_prefixed() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let archive_dir = repo_root.join("openspec/changes/archive/2024-01-15-test-archiving");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
let result = has_archive_files("test-archiving", repo_root)
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_has_archive_files_not_found() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
let result = has_archive_files("nonexistent", repo_root).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_detect_workspace_state_archiving() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-test-archiving"])
.current_dir(repo_root)
.output()
.unwrap();
commit(repo_root, "Apply: test-archiving");
let archive_dir = repo_root.join("openspec/changes/archive/test-archiving");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
let state = detect_workspace_state("test-archiving", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Archiving);
}
#[tokio::test]
async fn test_detect_workspace_state_archiving_date_prefixed() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-test-date-arch"])
.current_dir(repo_root)
.output()
.unwrap();
commit(repo_root, "Apply: test-date-arch");
let archive_dir = repo_root.join("openspec/changes/archive/2024-01-15-test-date-arch");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Test").unwrap();
let state = detect_workspace_state("test-date-arch", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Archiving);
}
#[tokio::test]
async fn test_detect_workspace_state_archived_file_state_only() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-file-state-test"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/file-state-test");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# File State Test").unwrap();
commit(repo_root, "Some other commit message");
let state = detect_workspace_state("file-state-test", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Archived);
}
#[tokio::test]
async fn test_detect_workspace_state_not_archived_without_archive_entry() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
commit(repo_root, "Initial commit");
StdCommand::new("git")
.args(["checkout", "-b", "workspace-no-archive-entry"])
.current_dir(repo_root)
.output()
.unwrap();
commit(repo_root, "Archive: no-archive-entry");
let state = detect_workspace_state("no-archive-entry", repo_root, "main")
.await
.unwrap();
assert_eq!(state, WorkspaceState::Created);
}
}