use std::future::Future;
use std::path::Path;
use tokio::process::Command;
use tracing::{debug, warn};
use crate::agent::{AgentRunner, OutputLine};
use crate::error::{OrchestratorError, Result};
use crate::hooks::HookContext;
use crate::task_parser;
use crate::vcs::git::commands as git_commands;
use crate::vcs::VcsBackend;
pub const ARCHIVE_COMMAND_MAX_RETRIES: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveVerificationResult {
Success,
NotArchived {
change_id: String,
},
}
impl ArchiveVerificationResult {
pub fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
fn archive_entry_exists(change_id: &str, archive_dir: &Path) -> bool {
if !archive_dir.exists() {
return false;
}
std::fs::read_dir(archive_dir)
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
name_str == change_id || name_str.ends_with(&format!("-{}", change_id))
})
})
.unwrap_or(false)
}
fn find_archive_entry_path(change_id: &str, archive_dir: &Path) -> Option<std::path::PathBuf> {
if !archive_dir.exists() {
return None;
}
std::fs::read_dir(archive_dir)
.ok()
.and_then(|entries| {
entries.filter_map(|e| e.ok()).find(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
name_str == change_id || name_str.ends_with(&format!("-{}", change_id))
})
})
.map(|entry| entry.path())
}
#[allow(dead_code)]
pub fn is_change_archived(change_id: &str, base_path: Option<&Path>) -> bool {
let (change_path, archive_dir) = match base_path {
Some(base) => (
base.join("openspec/changes").join(change_id),
base.join("openspec/changes/archive"),
),
None => (
Path::new("openspec/changes").join(change_id),
Path::new("openspec/changes/archive").to_path_buf(),
),
};
let change_exists = change_path.exists();
let archive_exists = archive_entry_exists(change_id, &archive_dir);
debug!(
change_id = %change_id,
change_path = %change_path.display(),
archive_dir = %archive_dir.display(),
change_exists = change_exists,
archive_exists = archive_exists,
"is_change_archived: checking paths"
);
archive_exists && !change_exists
}
pub async fn is_archive_commit_complete(change_id: &str, base_path: Option<&Path>) -> Result<bool> {
let repo_root = base_path.unwrap_or_else(|| Path::new("."));
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_clean = 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_dir = repo_root.join("openspec/changes/archive");
let archive_exists = archive_entry_exists(change_id, &archive_dir);
debug!(
change_id = %change_id,
is_clean = is_clean,
change_path = %change_path.display(),
change_exists = change_exists,
archive_dir = %archive_dir.display(),
archive_exists = archive_exists,
"is_archive_commit_complete: checking file state (clean={}, change_gone={}, archive_exists={})",
is_clean,
!change_exists,
archive_exists
);
Ok(is_clean && !change_exists && archive_exists)
}
async fn try_direct_archive_commit(change_id: &str, repo_root: &Path) -> Result<bool> {
let commit_message = format!("Archive: {}", change_id);
debug!(
change_id = %change_id,
repo_root = %repo_root.display(),
"Attempting direct archive commit before AI resolve"
);
let add_output = Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.await
.map_err(|e| OrchestratorError::GitCommand(format!("Failed to run 'git add -A': {}", e)))?;
if !add_output.status.success() {
let stderr = String::from_utf8_lossy(&add_output.stderr);
return Err(OrchestratorError::GitCommand(format!(
"Failed to stage files for archive commit: {}",
stderr.trim()
)));
}
let commit_output = Command::new("git")
.args(["commit", "-m", &commit_message])
.current_dir(repo_root)
.output()
.await
.map_err(|e| {
OrchestratorError::GitCommand(format!("Failed to run direct archive commit: {}", e))
})?;
if !commit_output.status.success() {
let stderr = String::from_utf8_lossy(&commit_output.stderr);
warn!(
change_id = %change_id,
repo_root = %repo_root.display(),
exit_code = ?commit_output.status.code(),
stderr = %stderr.trim(),
"Direct archive commit failed; falling back to AI resolve"
);
return Ok(false);
}
debug!(
change_id = %change_id,
repo_root = %repo_root.display(),
"Direct archive commit succeeded"
);
Ok(true)
}
pub async fn ensure_archive_commit<F, Fut>(
change_id: &str,
repo_root: &Path,
agent: &AgentRunner,
ai_runner: &crate::ai_command_runner::AiCommandRunner,
vcs_backend: VcsBackend,
mut handle_output: F,
) -> Result<()>
where
F: FnMut(OutputLine) -> Fut,
Fut: Future<Output = ()>,
{
match vcs_backend {
VcsBackend::Git | VcsBackend::Auto => {
let is_git_repo = git_commands::check_git_repo(repo_root)
.await
.map_err(OrchestratorError::from_vcs_error)?;
if !is_git_repo {
if matches!(vcs_backend, VcsBackend::Git) {
if !repo_root.exists() {
warn!(
"Workspace directory {:?} no longer exists (likely deleted by archive command), skipping archive commit creation",
repo_root
);
return Ok(());
}
return Err(OrchestratorError::GitCommand(format!(
"Git repository not found at {}",
repo_root.display()
)));
}
debug!(
"Workspace {:?} is not a Git repository (likely deleted by archive command), skipping archive commit creation",
repo_root
);
return Ok(());
}
let change_path = repo_root.join("openspec/changes").join(change_id);
if change_path.exists() {
return Err(OrchestratorError::AgentCommand(format!(
"Cannot create archive commit for '{}': change directory still exists at {}. \
The archive operation did not properly move the change to the archive directory.",
change_id,
change_path.display()
)));
}
if is_archive_commit_complete(change_id, Some(repo_root)).await? {
return Ok(());
}
let (has_changes, _) = git_commands::has_uncommitted_changes(repo_root)
.await
.map_err(OrchestratorError::from_vcs_error)?;
if !has_changes {
let subject = git_commands::run_git(&["log", "-1", "--format=%s"], repo_root)
.await
.map_err(OrchestratorError::from_vcs_error)?;
let subject = subject.trim();
let wip_prefix = format!("WIP(archive): {}", change_id);
if subject.starts_with(&wip_prefix) {
match git_commands::squash_archive_wip_commits(repo_root, change_id).await {
Ok(()) => {
if is_archive_commit_complete(change_id, Some(repo_root)).await? {
return Ok(());
}
}
Err(err) => {
warn!(
change_id = %change_id,
error = %err,
"Failed to squash WIP(archive) commits before resolving archive"
);
}
}
}
} else if try_direct_archive_commit(change_id, repo_root).await?
&& is_archive_commit_complete(change_id, Some(repo_root)).await?
{
return Ok(());
}
let prompt = format!(
"You are finalizing the archive commit for change '{change_id}'.\n\n\
Requirements:\n\
1) Ensure `git status --porcelain` is empty when done.\n\
2) If there are changes, run `git add -A` and commit with message \"Archive: {change_id}\".\n\
3) If a pre-commit hook modifies files or stops the commit, re-run `git add -A` and commit with the same message.\n\
4) If the latest commit already has subject \"Archive: {change_id}\" and the working tree is clean, do nothing.\n\
5) Do not use destructive commands like `git reset --hard`.",
change_id = change_id
);
let (mut child, mut rx) = agent
.run_resolve_streaming_in_dir_with_runner(&prompt, repo_root, ai_runner)
.await?;
while let Some(line) = rx.recv().await {
handle_output(line).await;
}
let status = child.wait().await.map_err(|e| {
OrchestratorError::AgentCommand(format!(
"Archive resolve command failed for change '{}' in workspace '{}': {}",
change_id,
repo_root.display(),
e
))
})?;
if !status.success() {
return Err(OrchestratorError::AgentCommand(format!(
"Archive resolve command failed for change '{}' in workspace '{}' with exit code: {:?}",
change_id,
repo_root.display(),
status.code()
)));
}
if !is_archive_commit_complete(change_id, Some(repo_root)).await? {
return Err(OrchestratorError::AgentCommand(format!(
"Archive commit verification failed for change '{}' in workspace '{}'",
change_id,
repo_root.display()
)));
}
}
}
Ok(())
}
#[cfg(test)]
pub fn delete_change_directory(change_id: &str, base_path: Option<&Path>) -> Result<()> {
use tracing::info;
let change_path = match base_path {
Some(base) => base.join("openspec/changes").join(change_id),
None => Path::new("openspec/changes").join(change_id),
};
debug!(
change_id = %change_id,
change_path = %change_path.display(),
"delete_change_directory: attempting to delete"
);
if !change_path.exists() {
debug!(
change_id = %change_id,
"delete_change_directory: directory does not exist, skipping"
);
return Ok(());
}
std::fs::remove_dir_all(&change_path).map_err(|e| {
OrchestratorError::ConfigLoad(format!(
"Failed to delete change directory '{}' at {}: {}",
change_id,
change_path.display(),
e
))
})?;
info!(
change_id = %change_id,
change_path = %change_path.display(),
"delete_change_directory: successfully deleted"
);
Ok(())
}
pub fn verify_archive_completion(
change_id: &str,
base_path: Option<&Path>,
) -> ArchiveVerificationResult {
let (change_path, archive_dir) = match base_path {
Some(base) => (
base.join("openspec/changes").join(change_id),
base.join("openspec/changes/archive"),
),
None => (
Path::new("openspec/changes").join(change_id),
Path::new("openspec/changes/archive").to_path_buf(),
),
};
let change_exists = change_path.exists();
let archive_exists = archive_entry_exists(change_id, &archive_dir);
debug!(
change_id = %change_id,
change_path = %change_path.display(),
archive_dir = %archive_dir.display(),
change_exists = change_exists,
archive_exists = archive_exists,
"verify_archive_completion: checking paths"
);
if !change_exists {
ArchiveVerificationResult::Success
} else {
ArchiveVerificationResult::NotArchived {
change_id: change_id.to_string(),
}
}
}
#[allow(dead_code)] pub fn verify_task_completion(change_id: &str, base_path: Option<&Path>) -> Result<bool> {
let tasks_path = match base_path {
Some(base) => base
.join("openspec/changes")
.join(change_id)
.join("tasks.md"),
None => Path::new("openspec/changes")
.join(change_id)
.join("tasks.md"),
};
debug!(
change_id = %change_id,
tasks_path = %tasks_path.display(),
"verify_task_completion: checking tasks"
);
if !tasks_path.exists() {
return Err(OrchestratorError::ConfigLoad(format!(
"Tasks file not found for change '{}': {:?}",
change_id, tasks_path
)));
}
let progress = task_parser::parse_file(&tasks_path, Some(change_id))?;
debug!(
change_id = %change_id,
completed = progress.completed,
total = progress.total,
"verify_task_completion: parsed progress"
);
Ok(progress.total > 0 && progress.completed >= progress.total)
}
pub fn get_task_progress(
change_id: &str,
base_path: Option<&Path>,
) -> Result<Option<task_parser::TaskProgress>> {
let tasks_path = match base_path {
Some(base) => base
.join("openspec/changes")
.join(change_id)
.join("tasks.md"),
None => Path::new("openspec/changes")
.join(change_id)
.join("tasks.md"),
};
if tasks_path.exists() {
let progress = task_parser::parse_file(&tasks_path, Some(change_id))?;
return Ok(Some(progress));
}
let archive_dir = match base_path {
Some(base) => base.join("openspec/changes/archive"),
None => Path::new("openspec/changes/archive").to_path_buf(),
};
if let Some(archive_entry_path) = find_archive_entry_path(change_id, &archive_dir) {
let archive_tasks_path = archive_entry_path.join("tasks.md");
if archive_tasks_path.exists() {
debug!(
change_id = %change_id,
archive_tasks_path = %archive_tasks_path.display(),
"get_task_progress: using archive fallback"
);
let progress = task_parser::parse_file(&archive_tasks_path, Some(change_id))?;
return Ok(Some(progress));
}
}
Ok(None)
}
pub fn extract_archive_runtime_blocker(
stdout_tail: Option<&str>,
stderr_tail: Option<&str>,
) -> Option<String> {
fn extract_line(text: &str) -> Option<String> {
let candidates = [
"validation failed",
"not archive-ready",
"archive readiness",
"precondition",
"blocker",
"failed",
];
text.lines().rev().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if candidates
.iter()
.any(|needle| lower.contains(&needle.to_ascii_lowercase()))
{
return Some(trimmed.to_string());
}
None
})
}
extract_line(stdout_tail.unwrap_or("")).or_else(|| extract_line(stderr_tail.unwrap_or("")))
}
pub fn build_archive_error_message(
change_id: &str,
workspace_path: Option<&Path>,
runtime_blocker: Option<&str>,
) -> String {
let root_cause = runtime_blocker
.map(|b| format!(" Root cause from archive attempt: {}.", b))
.unwrap_or_default();
match workspace_path {
Some(path) => format!(
"Archive command did not complete for change '{}' in workspace '{}'. \
The change directory still exists in openspec/changes/ after archive verification.{} \
Treat this as an archive failure with preserved root cause instead of a generic verification-only failure.",
change_id,
path.display(),
root_cause
),
None => format!(
"Archive command did not complete for change '{}'. \
The change directory still exists in openspec/changes/ after archive verification.{} \
Treat this as an archive failure with preserved root cause instead of a generic verification-only failure.",
change_id,
root_cause
),
}
}
#[allow(dead_code)]
pub trait ArchiveEventHandler {
fn on_archive_started(&self, change_id: &str, command: &str);
fn on_hook_started(&self, change_id: &str, hook_type: &str);
fn on_hook_completed(&self, change_id: &str, hook_type: &str);
fn on_hook_failed(&self, change_id: &str, hook_type: &str, error: &str);
fn on_archive_output(&self, change_id: &str, line: &OutputLine);
}
#[allow(dead_code)]
pub struct NoOpArchiveEventHandler;
impl ArchiveEventHandler for NoOpArchiveEventHandler {
fn on_archive_started(&self, _change_id: &str, _command: &str) {}
fn on_hook_started(&self, _change_id: &str, _hook_type: &str) {}
fn on_hook_completed(&self, _change_id: &str, _hook_type: &str) {}
fn on_hook_failed(&self, _change_id: &str, _hook_type: &str, _error: &str) {}
fn on_archive_output(&self, _change_id: &str, _line: &OutputLine) {}
}
#[allow(dead_code)]
pub struct ArchiveLoopHookContext {
pub changes_processed: usize,
pub total_changes: usize,
pub remaining_changes: usize,
pub apply_count: u32,
pub workspace_path: Option<String>,
pub group_index: Option<usize>,
}
#[allow(dead_code)]
impl ArchiveLoopHookContext {
pub fn serial(
changes_processed: usize,
total_changes: usize,
remaining_changes: usize,
apply_count: u32,
) -> Self {
Self {
changes_processed,
total_changes,
remaining_changes,
apply_count,
workspace_path: None,
group_index: None,
}
}
pub fn parallel(
changes_processed: usize,
total_changes: usize,
remaining_changes: usize,
apply_count: u32,
workspace_path: String,
group_index: usize,
) -> Self {
Self {
changes_processed,
total_changes,
remaining_changes,
apply_count,
workspace_path: Some(workspace_path),
group_index: Some(group_index),
}
}
fn build_hook_context(&self, change_id: &str, completed: u32, total: u32) -> HookContext {
let mut ctx = HookContext::new(
self.changes_processed,
self.total_changes,
self.remaining_changes,
false,
)
.with_change(change_id, completed, total)
.with_apply_count(self.apply_count);
if let Some(ref workspace_path) = self.workspace_path {
if let Some(group_index) = self.group_index {
ctx = ctx.with_parallel_context(workspace_path, Some(group_index as u32));
}
}
ctx
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct ArchiveLoopResult {
pub succeeded: bool,
pub attempts: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentRunner;
use crate::config::OrchestratorConfig;
use crate::vcs::VcsBackend;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn make_openspec_dirs(base: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
let changes_dir = base.join("openspec/changes");
let archive_dir = base.join("openspec/changes/archive");
fs::create_dir_all(&changes_dir).unwrap();
fs::create_dir_all(&archive_dir).unwrap();
(changes_dir, archive_dir)
}
fn init_git_repo(repo_root: &Path) {
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo_root)
.output()
.unwrap();
}
fn make_ai_runner(config: &OrchestratorConfig) -> crate::ai_command_runner::AiCommandRunner {
use crate::ai_command_runner::{AiCommandRunner, SharedStaggerState};
use crate::command_queue::CommandQueueConfig;
use crate::config::defaults::*;
use std::sync::Arc;
use tokio::sync::Mutex;
let queue_config = CommandQueueConfig {
stagger_delay_ms: config
.command_queue_stagger_delay_ms
.unwrap_or(DEFAULT_STAGGER_DELAY_MS),
max_retries: config
.command_queue_max_retries
.unwrap_or(DEFAULT_MAX_RETRIES),
retry_delay_ms: config
.command_queue_retry_delay_ms
.unwrap_or(DEFAULT_RETRY_DELAY_MS),
retry_error_patterns: config
.command_queue_retry_patterns
.clone()
.unwrap_or_else(default_retry_patterns),
retry_if_duration_under_secs: config
.command_queue_retry_if_duration_under_secs
.unwrap_or(DEFAULT_RETRY_IF_DURATION_UNDER_SECS),
inactivity_timeout_secs: config.get_command_inactivity_timeout_secs(),
inactivity_kill_grace_secs: config.get_command_inactivity_kill_grace_secs(),
inactivity_timeout_max_retries: config.get_command_inactivity_timeout_max_retries(),
strict_process_cleanup: config.get_command_strict_process_cleanup(),
};
let shared_stagger_state: SharedStaggerState = Arc::new(Mutex::new(None));
AiCommandRunner::new(queue_config, shared_stagger_state)
}
#[test]
fn test_verify_archive_change_not_archived() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (changes_dir, _archive_dir) = make_openspec_dirs(base);
let change_id = "my-change";
let change_path = changes_dir.join(change_id);
fs::create_dir(&change_path).unwrap();
let result = verify_archive_completion(change_id, Some(base));
assert!(!result.is_success());
assert_eq!(
result,
ArchiveVerificationResult::NotArchived {
change_id: "my-change".to_string()
}
);
}
#[test]
fn test_verify_archive_change_moved_to_archive() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (_changes_dir, archive_dir) = make_openspec_dirs(base);
let change_id = "my-change";
let archive_path = archive_dir.join(change_id);
fs::create_dir(&archive_path).unwrap();
let result = verify_archive_completion(change_id, Some(base));
assert!(result.is_success());
}
#[test]
fn test_verify_archive_date_prefixed() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (_changes_dir, archive_dir) = make_openspec_dirs(base);
let change_id = "my-change";
let archive_path = archive_dir.join("2024-01-15-my-change");
fs::create_dir(&archive_path).unwrap();
let result = verify_archive_completion(change_id, Some(base));
assert!(result.is_success());
}
#[test]
fn test_verify_archive_change_removed() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (_changes_dir, _archive_dir) = make_openspec_dirs(base);
let change_id = "my-change";
let result = verify_archive_completion(change_id, Some(base));
assert!(result.is_success());
}
#[test]
fn test_verify_archive_both_exist() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (changes_dir, archive_dir) = make_openspec_dirs(base);
let change_id = "my-change";
let change_path = changes_dir.join(change_id);
let archive_path = archive_dir.join(change_id);
fs::create_dir(&change_path).unwrap();
fs::create_dir(&archive_path).unwrap();
let result = verify_archive_completion(change_id, Some(base));
assert!(!result.is_success());
assert_eq!(
result,
ArchiveVerificationResult::NotArchived {
change_id: "my-change".to_string()
}
);
}
#[test]
fn test_is_change_archived_when_archive_only() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (_changes_dir, archive_dir) = make_openspec_dirs(base);
let change_id = "archived-change";
let archive_path = archive_dir.join(change_id);
fs::create_dir(&archive_path).unwrap();
assert!(is_change_archived(change_id, Some(base)));
}
#[test]
fn test_is_change_archived_false_when_change_exists() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (changes_dir, archive_dir) = make_openspec_dirs(base);
let change_id = "active-change";
let change_path = changes_dir.join(change_id);
let archive_path = archive_dir.join(change_id);
fs::create_dir(&change_path).unwrap();
fs::create_dir(&archive_path).unwrap();
assert!(!is_change_archived(change_id, Some(base)));
}
#[test]
fn test_is_change_archived_false_without_archive_entry() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let (_changes_dir, _archive_dir) = make_openspec_dirs(base);
let change_id = "missing-archive";
assert!(!is_change_archived(change_id, Some(base)));
}
#[tokio::test]
async fn test_is_archive_commit_complete_when_clean() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
let archive_dir = repo_root.join("openspec/changes/archive/change-a");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Archived").unwrap();
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Archive: change-a"])
.current_dir(repo_root)
.output()
.unwrap();
let result = is_archive_commit_complete("change-a", Some(repo_root))
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_is_archive_commit_complete_false_when_dirty() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
let archive_dir = repo_root.join("openspec/changes/archive/change-a");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("proposal.md"), "# Archived").unwrap();
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Archive: change-a"])
.current_dir(repo_root)
.output()
.unwrap();
fs::write(repo_root.join("README.md"), "dirty").unwrap();
let result = is_archive_commit_complete("change-a", Some(repo_root))
.await
.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_direct_archive_commit_success_skips_ai_resolve() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Base"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/change-a");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("archive.txt"), "archived").unwrap();
let config = OrchestratorConfig {
resolve_command: Some("sh -c 'exit 42'".to_string()),
..Default::default()
};
let agent = AgentRunner::new(config.clone());
let ai_runner = make_ai_runner(&config);
ensure_archive_commit(
"change-a",
repo_root,
&agent,
&ai_runner,
VcsBackend::Git,
|_| async {},
)
.await
.unwrap();
let result = is_archive_commit_complete("change-a", Some(repo_root))
.await
.unwrap();
assert!(result);
}
#[cfg(unix)]
#[test]
fn test_direct_archive_commit_fallback_to_ai_resolve_on_pre_commit_failure() {
use std::os::unix::fs::PermissionsExt;
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Base"])
.current_dir(repo_root)
.output()
.unwrap();
let archive_dir = repo_root.join("openspec/changes/archive/change-a");
fs::create_dir_all(&archive_dir).unwrap();
fs::write(archive_dir.join("archive.txt"), "archived").unwrap();
let hooks_dir = repo_root.join(".git/hooks");
let hook_path = hooks_dir.join("pre-commit");
let hook_contents = "#!/bin/sh\n\
if [ ! -f .git/hooks/pre-commit-ran ]; then\n\
echo 'hooked' >> openspec/changes/archive/change-a/archive.txt\n\
git add openspec/changes/archive/change-a/archive.txt\n\
touch .git/hooks/pre-commit-ran\n\
exit 1\n\
fi\n\
exit 0\n";
fs::write(&hook_path, hook_contents).unwrap();
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
let resolver_script = repo_root.join("archive-resolver.sh");
let script_contents = "#!/bin/sh\nset -e\n\
git add -A\n\
if ! git commit -m 'Archive: change-a'; then\n\
git add -A\n\
git commit -m 'Archive: change-a'\n\
fi\n";
fs::write(&resolver_script, script_contents).unwrap();
let mut perms = fs::metadata(&resolver_script).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&resolver_script, perms).unwrap();
let config = OrchestratorConfig {
resolve_command: Some("sh archive-resolver.sh".to_string()),
..Default::default()
};
let agent = AgentRunner::new(config.clone());
let ai_runner = make_ai_runner(&config);
ensure_archive_commit(
"change-a",
repo_root,
&agent,
&ai_runner,
VcsBackend::Git,
|_| async {},
)
.await
.unwrap();
let result = is_archive_commit_complete("change-a", Some(repo_root))
.await
.unwrap();
assert!(result);
});
}
#[test]
fn test_verify_task_completion_all_complete() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "my-change";
let tasks_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&tasks_dir).unwrap();
let tasks_content = "# Tasks\n\n- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n";
fs::write(tasks_dir.join("tasks.md"), tasks_content).unwrap();
let result = verify_task_completion(change_id, Some(base)).unwrap();
assert!(result);
}
#[test]
fn test_verify_task_completion_incomplete() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "my-change";
let tasks_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&tasks_dir).unwrap();
let tasks_content = "# Tasks\n\n- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
fs::write(tasks_dir.join("tasks.md"), tasks_content).unwrap();
let result = verify_task_completion(change_id, Some(base)).unwrap();
assert!(!result);
}
#[test]
fn test_verify_task_completion_empty_file() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "my-change";
let tasks_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&tasks_dir).unwrap();
let tasks_content = "# Tasks\n\nNo actual task checkboxes here.\n";
fs::write(tasks_dir.join("tasks.md"), tasks_content).unwrap();
let result = verify_task_completion(change_id, Some(base)).unwrap();
assert!(!result);
}
#[test]
fn test_verify_task_completion_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "nonexistent-change";
let result = verify_task_completion(change_id, Some(base));
assert!(result.is_err());
}
#[test]
fn test_get_task_progress_exists() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "my-change";
let tasks_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&tasks_dir).unwrap();
let tasks_content = "# Tasks\n\n- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
fs::write(tasks_dir.join("tasks.md"), tasks_content).unwrap();
let progress = get_task_progress(change_id, Some(base)).unwrap().unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
}
#[test]
fn test_get_task_progress_not_found() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "nonexistent-change";
let progress = get_task_progress(change_id, Some(base)).unwrap();
assert!(progress.is_none());
}
#[test]
fn test_get_task_progress_archive_fallback() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "archived-change";
let archive_dir = base.join("openspec/changes/archive").join(change_id);
fs::create_dir_all(&archive_dir).unwrap();
let tasks_content = "# Tasks\n\n- [x] Task 1\n- [x] Task 2\n- [ ] Task 3\n";
fs::write(archive_dir.join("tasks.md"), tasks_content).unwrap();
let progress = get_task_progress(change_id, Some(base)).unwrap().unwrap();
assert_eq!(progress.completed, 2);
assert_eq!(progress.total, 3);
}
#[test]
fn test_get_task_progress_archive_fallback_date_prefixed() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "archived-change";
let archive_dir = base
.join("openspec/changes/archive")
.join("2024-01-15-archived-change");
fs::create_dir_all(&archive_dir).unwrap();
let tasks_content = "# Tasks\n\n- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n";
fs::write(archive_dir.join("tasks.md"), tasks_content).unwrap();
let progress = get_task_progress(change_id, Some(base)).unwrap().unwrap();
assert_eq!(progress.completed, 3);
assert_eq!(progress.total, 3);
}
#[test]
fn test_get_task_progress_primary_takes_precedence() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "both-locations";
let primary_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&primary_dir).unwrap();
let primary_tasks = "# Tasks\n\n- [x] Task 1\n- [ ] Task 2\n";
fs::write(primary_dir.join("tasks.md"), primary_tasks).unwrap();
let archive_dir = base.join("openspec/changes/archive").join(change_id);
fs::create_dir_all(&archive_dir).unwrap();
let archive_tasks = "# Tasks\n\n- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n";
fs::write(archive_dir.join("tasks.md"), archive_tasks).unwrap();
let progress = get_task_progress(change_id, Some(base)).unwrap().unwrap();
assert_eq!(progress.completed, 1);
assert_eq!(progress.total, 2);
}
#[test]
fn test_get_task_progress_archive_without_tasks_md() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "archived-no-tasks";
let archive_dir = base.join("openspec/changes/archive").join(change_id);
fs::create_dir_all(&archive_dir).unwrap();
let progress = get_task_progress(change_id, Some(base)).unwrap();
assert!(progress.is_none());
}
#[test]
fn test_build_archive_error_message() {
let msg = build_archive_error_message("add-feature", None, None);
assert!(msg.contains("add-feature"));
assert!(msg.contains("did not complete"));
assert!(msg.contains("openspec/changes"));
let msg_with_path =
build_archive_error_message("add-feature", Some(Path::new("/tmp/ws")), None);
assert!(msg_with_path.contains("add-feature"));
assert!(msg_with_path.contains("in workspace '/tmp/ws'"));
assert!(msg_with_path.contains("did not complete"));
let with_blocker = build_archive_error_message(
"add-feature",
Some(Path::new("/tmp/ws")),
Some("validation failed: canonical promotion check"),
);
assert!(with_blocker.contains("Root cause from archive attempt"));
}
#[tokio::test]
async fn test_is_archive_commit_complete_false_when_change_exists() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
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"), "# Archived").unwrap();
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Archive: test-change"])
.current_dir(repo_root)
.output()
.unwrap();
let change_dir = repo_root.join("openspec/changes/test-change");
fs::create_dir_all(&change_dir).unwrap();
let result = is_archive_commit_complete("test-change", Some(repo_root))
.await
.unwrap();
assert!(
!result,
"Archive commit should be incomplete when change directory exists"
);
}
#[tokio::test]
async fn test_is_archive_commit_complete_true_when_change_removed() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
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"), "# Archived").unwrap();
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Archive: test-change"])
.current_dir(repo_root)
.output()
.unwrap();
let result = is_archive_commit_complete("test-change", Some(repo_root))
.await
.unwrap();
assert!(
result,
"Archive commit should be complete when change directory does not exist and archive entry exists"
);
}
#[tokio::test]
async fn test_ensure_archive_commit_fails_when_change_exists() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
init_git_repo(repo_root);
fs::write(repo_root.join("README.md"), "base").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(repo_root)
.output()
.unwrap();
let change_dir = repo_root.join("openspec/changes/test-change");
fs::create_dir_all(&change_dir).unwrap();
fs::write(change_dir.join("spec.md"), "# Test change").unwrap();
Command::new("git")
.args(["add", "-A"])
.current_dir(repo_root)
.output()
.unwrap();
let config = OrchestratorConfig::default();
let agent = AgentRunner::new(config.clone());
let ai_runner = make_ai_runner(&config);
let result = ensure_archive_commit(
"test-change",
repo_root,
&agent,
&ai_runner,
VcsBackend::Git,
|_| async {},
)
.await;
assert!(
result.is_err(),
"ensure_archive_commit should fail when change directory exists"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("change directory still exists"),
"Error message should mention change directory exists, got: {}",
err_msg
);
}
#[test]
fn test_delete_change_directory_success() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "test-change";
let change_dir = base.join("openspec/changes").join(change_id);
fs::create_dir_all(&change_dir).unwrap();
fs::write(change_dir.join("tasks.md"), "test content").unwrap();
assert!(change_dir.exists());
let result = delete_change_directory(change_id, Some(base));
assert!(result.is_ok(), "Delete should succeed");
assert!(!change_dir.exists());
}
#[test]
fn test_delete_change_directory_idempotent() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "nonexistent-change";
let (changes_dir, _) = make_openspec_dirs(base);
assert!(changes_dir.exists());
let result = delete_change_directory(change_id, Some(base));
assert!(
result.is_ok(),
"Delete of non-existent directory should succeed"
);
}
#[test]
fn test_delete_change_directory_with_nested_content() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let change_id = "nested-change";
let change_dir = base.join("openspec/changes").join(change_id);
let specs_dir = change_dir.join("specs");
fs::create_dir_all(&specs_dir).unwrap();
fs::write(change_dir.join("tasks.md"), "tasks").unwrap();
fs::write(specs_dir.join("spec.md"), "spec content").unwrap();
assert!(change_dir.exists());
assert!(specs_dir.exists());
let result = delete_change_directory(change_id, Some(base));
assert!(result.is_ok(), "Delete should succeed");
assert!(!change_dir.exists());
assert!(!specs_dir.exists());
}
}