use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
use super::{
run_wip_snapshot_with_retry, GitWipSnapshotEnvironment, WipSnapshotEnvironment,
WIP_SNAPSHOT_MAX_ATTEMPTS, WIP_SNAPSHOT_RETRY_DELAY,
};
use crate::config::OrchestratorConfig;
use crate::execution::apply::create_progress_commit;
use crate::task_parser::TaskProgress;
use crate::vcs::git::commands::run_git;
use crate::vcs::git::GitWorkspaceManager;
use crate::vcs::{VcsBackend, VcsError};
const CHANGE_ID: &str = "demo-change";
const ITERATION: u32 = 2;
fn progress() -> TaskProgress {
TaskProgress::with_counts(1, 3)
}
fn expected_wip_message() -> String {
format!("WIP: {} (1/3 tasks, apply#{})", CHANGE_ID, ITERATION)
}
async fn git_ok(repo: &Path, args: &[&str]) {
run_git(args, repo)
.await
.unwrap_or_else(|error| panic!("git {:?} failed: {}", args, error));
}
async fn init_repo_with_apply_output(repo: &Path) {
git_ok(repo, &["init", "-q", "-b", "main"]).await;
git_ok(repo, &["config", "user.email", "test@example.com"]).await;
git_ok(repo, &["config", "user.name", "Test User"]).await;
git_ok(repo, &["config", "commit.gpgsign", "false"]).await;
fs::write(repo.join("base.txt"), "base\n").unwrap();
git_ok(repo, &["add", "-A"]).await;
git_ok(repo, &["commit", "-q", "-m", "base"]).await;
fs::write(repo.join("applied.txt"), "apply output\n").unwrap();
}
fn workspace_manager(repo: &Path) -> GitWorkspaceManager {
GitWorkspaceManager::new(
repo.join(".openspec-worktrees"),
repo.to_path_buf(),
1,
OrchestratorConfig::default(),
)
}
fn hold_index_lock(repo: &Path) -> std::path::PathBuf {
let lock = repo.join(".git").join("index.lock");
fs::write(&lock, "held by another git process\n").unwrap();
lock
}
async fn commit_subjects(repo: &Path) -> Vec<String> {
run_git(&["log", "--format=%s"], repo)
.await
.expect("git log")
.lines()
.map(str::to_string)
.collect()
}
#[tokio::test]
async fn transient_wip_commit_lock_recovers_when_contention_clears() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let lock = hold_index_lock(repo.path());
let released = lock.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
fs::remove_file(&released).expect("release index.lock");
});
create_progress_commit(
&workspace_manager(repo.path()),
repo.path(),
CHANGE_ID,
&progress(),
ITERATION,
None,
)
.await
.expect("snapshot must recover once contention clears");
let subjects = commit_subjects(repo.path()).await;
assert_eq!(
subjects
.iter()
.filter(|subject| *subject == &expected_wip_message())
.count(),
1,
"exactly one WIP commit expected, got {:?}",
subjects
);
let committed = run_git(&["show", "--name-only", "--format=", "HEAD"], repo.path())
.await
.expect("git show");
assert!(
committed.contains("applied.txt"),
"apply output missing from snapshot: {}",
committed
);
assert!(
!lock.exists(),
"the WIP retry policy must never delete a live lock"
);
}
#[tokio::test]
async fn transient_wip_commit_lock_exhaustion_preserves_workspace() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let lock = hold_index_lock(repo.path());
let started = Instant::now();
let error = create_progress_commit(
&workspace_manager(repo.path()),
repo.path(),
CHANGE_ID,
&progress(),
ITERATION,
None,
)
.await
.expect_err("a lock held past the retry budget must fail");
assert!(
started.elapsed() >= WIP_SNAPSHOT_RETRY_DELAY * (WIP_SNAPSHOT_MAX_ATTEMPTS - 1),
"five attempts must be separated by four fixed delays"
);
let VcsError::Command {
message,
command,
working_dir,
stderr,
..
} = &error
else {
panic!("expected a command error, got {:?}", error);
};
assert!(
message.contains(&format!(
"attempt {}/{}",
WIP_SNAPSHOT_MAX_ATTEMPTS, WIP_SNAPSHOT_MAX_ATTEMPTS
)),
"message must report the retry outcome: {}",
message
);
assert_eq!(command.as_deref(), Some("git add -A"));
assert_eq!(working_dir.as_deref(), Some(repo.path()));
assert!(stderr.as_deref().unwrap_or_default().contains("index.lock"));
assert!(
lock.exists(),
"the WIP retry policy must never delete a live lock"
);
assert_eq!(
fs::read_to_string(repo.path().join("applied.txt")).unwrap(),
"apply output\n"
);
assert_eq!(commit_subjects(repo.path()).await, vec!["base".to_string()]);
}
#[tokio::test]
async fn transient_wip_commit_lock_cancellation_stops_retrying() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let lock = hold_index_lock(repo.path());
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let error = create_progress_commit(
&workspace_manager(repo.path()),
repo.path(),
CHANGE_ID,
&progress(),
ITERATION,
Some(&cancel_token),
)
.await
.expect_err("cancelled retry must fail");
assert!(
error
.to_string()
.contains("cancellation observed before the retry delay"),
"error must explain the suppressed retry: {}",
error
);
assert!(lock.exists());
assert_eq!(commit_subjects(repo.path()).await, vec!["base".to_string()]);
}
#[tokio::test]
async fn transient_wip_commit_lock_does_not_retry_identity_failure() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
git_ok(repo.path(), &["config", "user.email", ""]).await;
git_ok(repo.path(), &["config", "user.name", ""]).await;
let error = create_progress_commit(
&workspace_manager(repo.path()),
repo.path(),
CHANGE_ID,
&progress(),
ITERATION,
None,
)
.await
.expect_err("an identity failure must be terminal");
assert!(
!error.to_string().contains("WIP snapshot failed on attempt"),
"a terminal failure keeps the original error: {}",
error
);
assert_eq!(commit_subjects(repo.path()).await, vec!["base".to_string()]);
}
async fn fabricated_lock_error(repo: &Path, wip_message: &str) -> VcsError {
let lock = run_git(&["rev-parse", "--absolute-git-dir"], repo)
.await
.expect("git dir");
let stderr = format!(
"fatal: Unable to create '{}/index.lock': File exists.\n\n\
Another git process seems to be running in this repository.\n",
lock
);
VcsError::Command {
backend: VcsBackend::Git,
message: format!("git commit failed: {}", stderr),
command: Some(format!(
"git commit --no-verify --allow-empty -m {}",
wip_message
)),
working_dir: Some(repo.to_path_buf()),
stderr: Some(stderr),
stdout: Some(String::new()),
}
}
#[tokio::test]
async fn transient_wip_commit_lock_ambiguous_success_is_not_duplicated() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let wip_message = expected_wip_message();
let mut attempts = 0_u32;
let result = run_wip_snapshot_with_retry(
|| {
attempts += 1;
let repo_path = repo.path();
let wip_message = wip_message.clone();
async move {
run_git(&["add", "-A"], repo_path).await?;
run_git(
&["commit", "--no-verify", "--allow-empty", "-m", &wip_message],
repo_path,
)
.await?;
Err(fabricated_lock_error(repo_path, &wip_message).await)
}
},
&GitWipSnapshotEnvironment,
repo.path(),
&wip_message,
None,
)
.await;
assert!(result.is_ok(), "unexpected error: {:?}", result.err());
assert_eq!(attempts, 1, "an ambiguous success must not be retried");
let subjects = commit_subjects(repo.path()).await;
assert_eq!(
subjects,
vec![wip_message.clone(), "base".to_string()],
"no duplicate WIP commit"
);
}
#[tokio::test]
async fn transient_wip_commit_lock_ambiguous_success_rejects_historical_commit() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let wip_message = expected_wip_message();
git_ok(repo.path(), &["add", "-A"]).await;
git_ok(repo.path(), &["commit", "-q", "-m", &wip_message]).await;
let mut attempts = 0_u32;
let error = run_wip_snapshot_with_retry(
|| {
attempts += 1;
let repo_path = repo.path();
let wip_message = wip_message.clone();
async move { Err(fabricated_lock_error(repo_path, &wip_message).await) }
},
&GitWipSnapshotEnvironment,
repo.path(),
&wip_message,
None,
)
.await
.expect_err("an unchanged HEAD is not a recorded snapshot");
assert_eq!(attempts, WIP_SNAPSHOT_MAX_ATTEMPTS);
assert!(
error.to_string().contains("did not clear"),
"unexpected error: {}",
error
);
}
#[tokio::test]
async fn transient_wip_commit_lock_environment_reports_managed_lock_identity() {
let repo = TempDir::new().unwrap();
init_repo_with_apply_output(repo.path()).await;
let lock_paths = GitWipSnapshotEnvironment
.lock_paths(repo.path())
.await
.expect("lock paths");
assert!(!lock_paths.is_empty());
assert!(lock_paths
.iter()
.all(|path| path.file_name().is_some_and(|name| name == "index.lock")));
assert!(
lock_paths.contains(&repo.path().join(".git").join("index.lock")),
"the workspace-derived lock path must be a candidate: {:?}",
lock_paths
);
}