use super::super::conflict::*;
use crate::vcs::WorkspaceManager;
use std::path::{Path, PathBuf};
use crate::parallel::conflict::fixtures::{ConflictProbe, MockWorkspaceManager};
#[tokio::test]
async fn test_detect_conflicts_no_conflicts() {
let manager = MockWorkspaceManager::new(vec![]);
let conflicts = detect_conflicts(&manager).await.unwrap();
assert!(conflicts.is_empty());
}
#[tokio::test]
async fn test_detect_conflicts_with_conflicts() {
let manager =
MockWorkspaceManager::new(vec!["src/main.rs".to_string(), "src/lib.rs".to_string()]);
let conflicts = detect_conflicts(&manager).await.unwrap();
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0], "src/main.rs");
assert_eq!(conflicts[1], "src/lib.rs");
}
#[tokio::test]
async fn test_get_vcs_status() {
let expected_status =
"# On branch test\n# Changes not staged for commit:\n# modified: src/main.rs";
let manager = MockWorkspaceManager::new(vec![]).with_status(expected_status.to_string());
let status = get_vcs_status(&manager).await.unwrap();
assert_eq!(status, expected_status);
}
#[tokio::test]
async fn test_get_vcs_log_for_revisions() {
let expected_log = "commit def456\nAuthor: Developer\nDate: 2024-01-02\n\nUpdate feature";
let manager = MockWorkspaceManager::new(vec![]).with_log(expected_log.to_string());
let log = get_vcs_log_for_revisions(&manager, &["rev1".to_string(), "rev2".to_string()])
.await
.unwrap();
assert_eq!(log, expected_log);
}
fn test_items() -> Vec<crate::parallel::resolve_state::SequentialMergeItem> {
crate::parallel::resolve_state::SequentialMergeItem::batch(
&["rev1".to_string()],
&["change1".to_string()],
&[PathBuf::from("/tmp/archive/change1")],
)
.expect("well-formed batch")
}
#[tokio::test]
async fn test_resolve_merges_with_retry_args_struct() {
let manager = MockWorkspaceManager::new(vec![]);
let config = crate::config::OrchestratorConfig::default();
let items = test_items();
let target_branch = "main";
let base_revision = "base123";
let max_retries = 3;
let ai_runner = crate::ai_command_runner::AiCommandRunner::for_run(
&config,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
crate::ai_command_runner::RunCommandScope::new(),
);
let auto_resolve_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let args = ResolveMergesWithRetryArgs {
workspace_manager: &manager as &dyn WorkspaceManager,
config: &config,
event_tx: &None,
items: &items,
target_branch,
base_revision,
max_retries,
ai_runner,
auto_resolve_count,
publication_owns_completion: false,
};
assert_eq!(args.target_branch, "main");
assert_eq!(args.base_revision, "base123");
assert_eq!(args.max_retries, 3);
assert_eq!(args.items.len(), 1);
assert_eq!(args.items[0].change_id, "change1");
assert_eq!(
args.items[0].archive_path,
PathBuf::from("/tmp/archive/change1")
);
}
#[test]
fn test_resolve_merges_with_retry_args_clone() {
let manager = MockWorkspaceManager::new(vec![]);
let config = crate::config::OrchestratorConfig::default();
let items = test_items();
let ai_runner = crate::ai_command_runner::AiCommandRunner::for_run(
&config,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
crate::ai_command_runner::RunCommandScope::new(),
);
let auto_resolve_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let args1 = ResolveMergesWithRetryArgs {
workspace_manager: &manager as &dyn WorkspaceManager,
config: &config,
event_tx: &None,
items: &items,
target_branch: "main",
base_revision: "base123",
max_retries: 3,
ai_runner,
auto_resolve_count,
publication_owns_completion: false,
};
let args2 = args1.clone(); let _args3 = args1;
assert_eq!(args2.target_branch, "main");
}
async fn run_auto_resolve_and_report_counter(
probe: ConflictProbe,
) -> (bool, usize, tempfile::TempDir) {
let repo_dir = tempfile::TempDir::new().expect("create temp repo dir");
let manager = MockWorkspaceManager::new(vec!["conflict.txt".to_string()])
.with_repo_root(repo_dir.path().to_path_buf())
.with_probe(probe);
let config = crate::config::OrchestratorConfig {
resolve_command: Some("echo resolve".to_string()),
..Default::default()
};
let auto_resolve_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let result = resolve_conflicts_with_retry(
&manager as &dyn WorkspaceManager,
&config,
&None,
&["rev1".to_string()],
&["change-a".to_string()],
"merge conflict",
1,
crate::ai_command_runner::AiCommandRunner::for_run(
&config,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
crate::ai_command_runner::RunCommandScope::new(),
),
auto_resolve_count.clone(),
)
.await;
let remaining = auto_resolve_count.load(std::sync::atomic::Ordering::SeqCst);
(result.is_ok(), remaining, repo_dir)
}
#[tokio::test]
async fn auto_resolve_releases_capacity_on_success() {
let (succeeded, remaining, _repo_dir) =
run_auto_resolve_and_report_counter(ConflictProbe::ClearedAfterFirstCall).await;
assert!(succeeded, "cleared conflicts should complete resolution");
assert_eq!(
remaining, 0,
"successful automatic resolve must release its scheduler slot"
);
}
#[tokio::test]
async fn auto_resolve_releases_capacity_on_failure() {
let (succeeded, remaining, _repo_dir) =
run_auto_resolve_and_report_counter(ConflictProbe::Static).await;
assert!(
!succeeded,
"unresolved conflicts should exhaust retries and fail"
);
assert_eq!(
remaining, 0,
"failed automatic resolve must release its scheduler slot"
);
}
#[tokio::test]
async fn auto_resolve_releases_capacity_on_early_return() {
let (succeeded, remaining, _repo_dir) =
run_auto_resolve_and_report_counter(ConflictProbe::AlwaysFails).await;
assert!(
!succeeded,
"conflict detection failure should return early with an error"
);
assert_eq!(
remaining, 0,
"early-return automatic resolve must release its scheduler slot"
);
}
use crate::parallel::resolve_state::{self, BatchState, SequentialMergeItem};
use crate::parallel::resolve_state::fixtures::{
archived, item, live, presynced_repo, with_complete_tasks, FakeEvidence, FakeRepo,
};
#[tokio::test]
async fn absent_manager_entry_still_reports_the_supplied_worktree_path() {
let repo = presynced_repo();
let evidence = FakeEvidence::new(repo, "t").worktree("ws-a", "/wt/change-a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/change-a")];
let rendered = super::super::conflict::render_worktree_locations(&evidence, &items).await;
assert!(
rendered.contains("/wt/change-a"),
"supplied worktree path must be reported, got {}",
rendered
);
assert!(
!rendered.contains("(unknown)"),
"process-local workspace absence must not degrade to '(unknown)', got {}",
rendered
);
}
#[tokio::test]
async fn unsafe_worktree_identity_blocks_every_action() {
let evidence =
FakeEvidence::new(presynced_repo(), "t").unsafe_worktree("ws-a", "detached HEAD");
let items = vec![item("ws-a", "change-a", "/wt/change-a")];
let state = resolve_state::classify_batch(&evidence, &items, "base").await;
assert!(
matches!(state, BatchState::UnsafeEvidence { .. }),
"{:?}",
state
);
assert!(!state.allows_agent_action());
let rendered = super::super::conflict::render_worktree_locations(&evidence, &items).await;
assert!(
rendered.contains("unvalidated: detached HEAD"),
"{}",
rendered
);
}
#[tokio::test]
async fn empty_batch_is_unsafe() {
let evidence = FakeEvidence::new(presynced_repo(), "t");
let state = resolve_state::classify_batch(&evidence, &[], "base").await;
assert!(
matches!(state, BatchState::UnsafeEvidence { .. }),
"{:?}",
state
);
}
#[test]
fn malformed_batches_are_rejected_before_resolve() {
assert!(SequentialMergeItem::batch(&[], &[], &[]).is_err());
assert!(SequentialMergeItem::batch(
&["rev1".to_string(), "rev2".to_string()],
&["change1".to_string()],
&[PathBuf::from("/a"), PathBuf::from("/b")],
)
.is_err());
assert!(
SequentialMergeItem::batch(&["rev1".to_string()], &["change1".to_string()], &[],).is_err()
);
assert!(SequentialMergeItem::batch(
&["rev1".to_string()],
&["".to_string()],
&[PathBuf::from("/a")],
)
.is_err());
let ordered = SequentialMergeItem::batch(
&["rev1".to_string(), "rev2".to_string()],
&["change1".to_string(), "change2".to_string()],
&[PathBuf::from("/a"), PathBuf::from("/b")],
)
.expect("well-formed batch");
assert_eq!(
SequentialMergeItem::change_ids(&ordered),
vec!["change1".to_string(), "change2".to_string()],
"declared order must survive batch construction"
);
}
#[tokio::test]
async fn target_state_on_first_parent_lineage_needs_no_presync() {
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a_tip", "Work on change-a", &["base"])
.tree("a_tip", &[&archived("change-a")])
.tree("base", &[]);
with_complete_tasks(&mut repo, "a_tip", "change-a");
let evidence = FakeEvidence::new(repo, "base").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
let state = resolve_state::classify_batch(&evidence, &items, "base").await;
assert!(
matches!(state, BatchState::FinalMergeMissing { .. }),
"target HEAD is already on the tip lineage, so classification proceeds to the final merge: {:?}",
state
);
}
#[tokio::test]
async fn valid_presync_merge_reaches_final_merge_missing() {
let evidence = FakeEvidence::new(presynced_repo(), "t").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
let state = resolve_state::classify_batch(&evidence, &items, "base").await;
match state {
BatchState::FinalMergeMissing {
change_id,
required_target_state,
..
} => {
assert_eq!(change_id, "change-a");
assert_eq!(required_target_state, "t");
}
other => panic!("expected final merge missing, got {:?}", other),
}
}
#[tokio::test]
async fn missing_duplicate_and_wrong_parent_presync_all_fail_closed() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut missing = FakeRepo::default();
missing
.commit("base", "Base", &[])
.commit("t", "Target advance", &["base"])
.commit("a_tip", "Work on change-a", &["base"]);
let state = resolve_state::classify_batch(
&FakeEvidence::new(missing, "t").worktree("ws-a", "/wt/a", "a_tip"),
&items,
"base",
)
.await;
assert!(
matches!(state, BatchState::PreSyncInvalid { .. }),
"{:?}",
state
);
let mut duplicated = FakeRepo::default();
duplicated
.commit("base", "Base", &[])
.commit("t", "Target advance", &["base"])
.commit("a1", "Work", &["base"])
.commit("p1", "Pre-sync base into change-a", &["a1", "t"])
.commit("a_tip", "Pre-sync base into change-a", &["p1", "t"]);
let state = resolve_state::classify_batch(
&FakeEvidence::new(duplicated, "t").worktree("ws-a", "/wt/a", "a_tip"),
&items,
"base",
)
.await;
assert!(
matches!(state, BatchState::PreSyncInvalid { .. }),
"{:?}",
state
);
let mut wrong = FakeRepo::default();
wrong
.commit("base", "Base", &[])
.commit("t_old", "Older target", &["base"])
.commit("t", "Target advance", &["t_old"])
.commit("a1", "Work", &["base"])
.commit("a_tip", "Pre-sync base into change-a", &["a1", "t_old"]);
let state = resolve_state::classify_batch(
&FakeEvidence::new(wrong, "t").worktree("ws-a", "/wt/a", "a_tip"),
&items,
"base",
)
.await;
match state {
BatchState::PreSyncInvalid { reason, .. } => {
assert!(reason.contains("non-first parent"), "{}", reason)
}
other => panic!("expected invalid pre-sync, got {:?}", other),
}
let mut single_parent = FakeRepo::default();
single_parent
.commit("base", "Base", &[])
.commit("t", "Target advance", &["base"])
.commit("a1", "Work", &["base"])
.commit("a_tip", "Pre-sync base into change-a", &["a1"]);
let state = resolve_state::classify_batch(
&FakeEvidence::new(single_parent, "t").worktree("ws-a", "/wt/a", "a_tip"),
&items,
"base",
)
.await;
match state {
BatchState::PreSyncInvalid { reason, .. } => {
assert!(reason.contains("parent(s)"), "{}", reason)
}
other => panic!("expected invalid pre-sync, got {:?}", other),
}
}
#[tokio::test]
async fn unfinished_worktree_merge_and_conflicts_report_presync_unfinished() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let evidence = FakeEvidence::new(presynced_repo(), "t")
.worktree("ws-a", "/wt/a", "a_tip")
.worktree_merging("/wt/a");
assert!(
matches!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::PreSyncUnfinished { .. }
),
"an unfinished worktree merge must be reported before any target guidance"
);
let evidence = FakeEvidence::new(presynced_repo(), "t")
.worktree("ws-a", "/wt/a", "a_tip")
.worktree_conflicted("/wt/a", &["src/lib.rs"]);
assert!(matches!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::PreSyncUnfinished { .. }
));
}
#[tokio::test]
async fn historical_ancestry_integration_is_exempt_from_presync_reconstruction() {
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a_tip", "Work on change-a", &["base"])
.commit("head", "Unrelated follow-up", &["a_tip"])
.tree("head", &[]);
let evidence = FakeEvidence::new(repo, "head").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete
);
}
#[tokio::test]
async fn exact_final_merge_topology_is_required() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut valid = presynced_repo();
valid
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.tree("merge", &[]);
let evidence = FakeEvidence::new(valid, "merge").worktree("ws-a", "/wt/a", "a_tip");
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete
);
let mut false_exact = presynced_repo();
false_exact
.commit("other", "Unrelated branch", &["base"])
.commit("merge", "Merge change: change-a", &["a_tip", "other"])
.tree("merge", &[]);
let evidence = FakeEvidence::new(false_exact, "merge").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("non-first parent"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut wrong_count = presynced_repo();
wrong_count
.commit("merge", "Merge change: change-a", &["t"])
.tree("merge", &[]);
let evidence = FakeEvidence::new(wrong_count, "merge").worktree("ws-a", "/wt/a", "a_tip");
assert!(matches!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::UnsafeEvidence { .. }
));
let mut duplicated = presynced_repo();
duplicated
.commit("merge1", "Merge change: change-a", &["t", "a_tip"])
.commit("merge2", "Merge change: change-a", &["merge1", "a_tip"])
.tree("merge2", &[]);
let evidence = FakeEvidence::new(duplicated, "merge2").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("exactly one is required"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
}
#[tokio::test]
async fn later_item_owns_target_merge_after_prior_completion() {
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.commit("merge_a", "Merge change: change-a", &["base", "a1"])
.commit("b_tip", "Pre-sync base into change-b", &["b1", "merge_a"])
.tree("merge_a", &[]);
let evidence = FakeEvidence::new(repo, "merge_a")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b_tip")
.merge_head("b_tip")
.index(&[&live("change-b"), &archived("change-b")]);
let items = vec![
item("ws-a", "change-a", "/wt/a"),
item("ws-b", "change-b", "/wt/b"),
];
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::TargetMergeUnfinished {
change_id,
requires_live_removal,
..
} => {
assert_eq!(change_id, "change-b");
assert!(
requires_live_removal,
"stage-0 index coexistence must predict resurrection before the final commit"
);
}
other => panic!(
"expected target merge unfinished for change-b, got {:?}",
other
),
}
}
#[tokio::test]
async fn conflict_stages_withhold_cleanup_guidance() {
let mut repo = presynced_repo();
repo.tree("t", &[]);
let evidence = FakeEvidence::new(repo, "t")
.worktree("ws-a", "/wt/a", "a_tip")
.merge_head("a_tip")
.conflicts(&["openspec/changes/change-a/proposal.md"])
.index(&[&live("change-a"), &archived("change-a")]);
let items = vec![item("ws-a", "change-a", "/wt/a")];
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::TargetMergeUnfinished {
requires_live_removal,
reason,
..
} => {
assert!(
!requires_live_removal,
"cleanup must not be authorized while conflict stages exist"
);
assert!(reason.contains("conflict stages"), "{}", reason);
}
other => panic!("expected target merge unfinished, got {:?}", other),
}
}
#[tokio::test]
async fn target_merge_owner_must_be_unique_and_first_incomplete() {
let items = vec![
item("ws-a", "change-a", "/wt/a"),
item("ws-b", "change-b", "/wt/b"),
];
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.tree("base", &[]);
let evidence = FakeEvidence::new(repo.clone(), "base")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b1")
.merge_head("stranger");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("does not match"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let evidence = FakeEvidence::new(repo.clone(), "base")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "a1")
.merge_head("a1");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("matches 2"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let evidence = FakeEvidence::new(repo.clone(), "base")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b1")
.merge_head("b1");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("first incomplete"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
}
#[tokio::test]
async fn out_of_order_integration_fails_closed() {
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.commit("merge_b", "Merge change: change-b", &["base", "b1"])
.tree("merge_b", &[]);
let evidence = FakeEvidence::new(repo, "merge_b")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b1");
let items = vec![
item("ws-a", "change-a", "/wt/a"),
item("ws-b", "change-b", "/wt/b"),
];
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("declared order"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
}
#[tokio::test]
async fn multi_change_clean_completion() {
let mut repo = FakeRepo::default();
repo.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.commit("merge_a", "Merge change: change-a", &["base", "a1"])
.commit("b_tip", "Pre-sync base into change-b", &["b1", "merge_a"])
.commit("merge_b", "Merge change: change-b", &["merge_a", "b_tip"])
.tree("merge_b", &[&archived("change-a"), &archived("change-b")]);
let evidence = FakeEvidence::new(repo, "merge_b")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b_tip");
let items = vec![
item("ws-a", "change-a", "/wt/a"),
item("ws-b", "change-b", "/wt/b"),
];
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete
);
}
#[tokio::test]
async fn committed_live_and_archive_coexistence_requires_forward_cleanup() {
let mut repo = presynced_repo();
repo.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.tree("merge", &[&live("change-a"), &archived("change-a")]);
let evidence = FakeEvidence::new(repo, "merge").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
let state = resolve_state::classify_batch(&evidence, &items, "base").await;
match &state {
BatchState::ResurrectionCleanupRequired { change_id, .. } => {
assert_eq!(change_id, "change-a")
}
other => panic!("expected resurrection cleanup, got {:?}", other),
}
assert!(
state
.diagnosis()
.contains("Cleanup resurrected change: change-a"),
"guidance must name the exact forward cleanup subject: {}",
state.diagnosis()
);
}
#[tokio::test]
async fn staged_only_cleanup_remains_incomplete() {
let mut repo = presynced_repo();
repo.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.tree("merge", &[&live("change-a"), &archived("change-a")]);
let evidence = FakeEvidence::new(repo, "merge")
.worktree("ws-a", "/wt/a", "a_tip")
.dirty();
let items = vec![item("ws-a", "change-a", "/wt/a")];
assert!(matches!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::ResurrectionCleanupRequired { .. }
));
}
#[tokio::test]
async fn valid_forward_cleanup_commit_completes_the_batch() {
let mut repo = presynced_repo();
repo.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["merge"],
)
.diff("cleanup", &[('D', &live("change-a"))])
.tree("merge", &[&live("change-a"), &archived("change-a")])
.tree("cleanup", &[&archived("change-a")]);
let evidence = FakeEvidence::new(repo, "cleanup").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete
);
}
#[tokio::test]
async fn invalid_cleanup_commits_fail_closed() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut unrelated = presynced_repo();
unrelated
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["merge"],
)
.diff(
"cleanup",
&[('D', &live("change-a")), ('M', &archived("change-a"))],
)
.tree("cleanup", &[&archived("change-a")]);
let evidence = FakeEvidence::new(unrelated, "cleanup").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("may only delete"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut merged_cleanup = presynced_repo();
merged_cleanup
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["merge", "a_tip"],
)
.diff("cleanup", &[('D', &live("change-a"))])
.tree("cleanup", &[&archived("change-a")]);
let evidence = FakeEvidence::new(merged_cleanup, "cleanup").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("exactly one"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
}
#[tokio::test]
async fn cleanup_must_be_a_forward_commit_on_the_target_lineage() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut side_branch = presynced_repo();
side_branch
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["merge"],
)
.commit("head", "Merge side cleanup", &["merge", "cleanup"])
.diff("cleanup", &[('D', &live("change-a"))])
.tree("merge", &[&live("change-a"), &archived("change-a")])
.tree("cleanup", &[&archived("change-a")])
.tree("head", &[&archived("change-a")]);
let evidence = FakeEvidence::new(side_branch, "head").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("first-parent lineage"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut before_integration = FakeRepo::default();
before_integration
.commit("base", "Base", &[])
.commit("t", "Target advance", &["base"])
.commit("a_tip", "Work on change-a", &["base"])
.commit("cleanup", "Cleanup resurrected change: change-a", &["t"])
.commit("head", "Merge branch ws-a", &["cleanup", "a_tip"])
.diff("cleanup", &[('D', &live("change-a"))])
.tree("a_tip", &[&archived("change-a")])
.tree("cleanup", &[&archived("change-a")])
.tree("head", &[&archived("change-a")]);
let evidence = FakeEvidence::new(before_integration, "head").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => assert!(
reason.contains("does not contain its committed integration"),
"{}",
reason
),
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut wrong_predecessor = presynced_repo();
wrong_predecessor
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit("filler", "Unrelated follow-up", &["merge"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["filler"],
)
.diff("cleanup", &[('D', &live("change-a"))])
.tree("merge", &[&live("change-a"), &archived("change-a")])
.tree("filler", &[&archived("change-a")])
.tree("cleanup", &[&archived("change-a")]);
let evidence =
FakeEvidence::new(wrong_predecessor, "cleanup").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => assert!(
reason.contains("does not hold the live/archive coexistence"),
"{}",
reason
),
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut forward = presynced_repo();
forward
.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.commit(
"cleanup",
"Cleanup resurrected change: change-a",
&["merge"],
)
.diff("cleanup", &[('D', &live("change-a"))])
.tree("merge", &[&live("change-a"), &archived("change-a")])
.tree("cleanup", &[&archived("change-a")]);
let evidence = FakeEvidence::new(forward, "cleanup").worktree("ws-a", "/wt/a", "a_tip");
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete,
"a direct forward cleanup on the target lineage is the accepted shape"
);
}
#[tokio::test]
async fn declared_order_is_verified_when_every_item_is_integrated() {
let items = vec![
item("ws-a", "change-a", "/wt/a"),
item("ws-b", "change-b", "/wt/b"),
];
let mut reversed = FakeRepo::default();
reversed
.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.commit("merge_b", "Merge change: change-b", &["base", "b1"])
.commit("merge_a", "Merge change: change-a", &["merge_b", "a1"])
.tree("merge_a", &[&archived("change-a"), &archived("change-b")]);
let evidence = FakeEvidence::new(reversed, "merge_a")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b1");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("declared order"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut ordered = FakeRepo::default();
ordered
.commit("base", "Base", &[])
.commit("a1", "Work a", &["base"])
.commit("b1", "Work b", &["base"])
.commit("merge_a", "Merge change: change-a", &["base", "a1"])
.commit("b_tip", "Pre-sync base into change-b", &["b1", "merge_a"])
.commit("merge_b", "Merge change: change-b", &["merge_a", "b_tip"])
.tree("merge_b", &[&archived("change-a"), &archived("change-b")]);
let evidence = FakeEvidence::new(ordered, "merge_b")
.worktree("ws-a", "/wt/a", "a1")
.worktree("ws-b", "/wt/b", "b_tip");
assert_eq!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::Complete
);
}
#[tokio::test]
async fn prefinal_archive_evidence_comes_from_the_validated_worktree_head() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut worktree_only = presynced_repo();
worktree_only
.tree("t", &[&live("change-a")])
.tree("a_tip", &[&archived("change-a")]);
with_complete_tasks(&mut worktree_only, "a_tip", "change-a");
let evidence = FakeEvidence::new(worktree_only, "t").worktree("ws-a", "/wt/a", "a_tip");
assert!(
matches!(
resolve_state::classify_batch(&evidence, &items, "base").await,
BatchState::FinalMergeMissing { .. }
),
"an archive committed only on the branch tip must still authorize the final merge"
);
let mut unarchived = presynced_repo();
unarchived.tree("a_tip", &[&live("change-a")]);
let evidence = FakeEvidence::new(unarchived, "t").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("no valid archive proposal"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
let mut nested = presynced_repo();
nested.tree(
"a_tip",
&["openspec/changes/archive/2026-08-03/change-a/proposal.md"],
);
let evidence = FakeEvidence::new(nested, "t").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("invalid nested layout"), "{}", reason)
}
other => panic!("expected unsafe evidence, got {:?}", other),
}
for archive_like in [
"openspec/changes/archive/prefix-change-a/proposal.md",
"openspec/changes/archive/change-a/design.md",
] {
let mut repo = presynced_repo();
repo.tree("a_tip", &[archive_like]);
let evidence = FakeEvidence::new(repo, "t").worktree("ws-a", "/wt/a", "a_tip");
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => assert!(
reason.contains("no valid archive proposal"),
"'{}' must not authorize the final merge: {}",
archive_like,
reason
),
other => panic!(
"'{}' must not authorize the final merge, got {:?}",
archive_like, other
),
}
}
}
#[tokio::test]
async fn invalid_archive_shapes_never_authorize_deletion() {
for archive_like in [
"openspec/changes/archive/2026-08-03/change-a/proposal.md",
"openspec/changes/archive/prefix-change-a/proposal.md",
"openspec/changes/archive/change-a/design.md",
] {
let mut repo = presynced_repo();
repo.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.tree("merge", &[&live("change-a"), archive_like]);
let evidence = FakeEvidence::new(repo, "merge").worktree("ws-a", "/wt/a", "a_tip");
let items = vec![item("ws-a", "change-a", "/wt/a")];
let state = resolve_state::classify_batch(&evidence, &items, "base").await;
assert!(
!matches!(state, BatchState::ResurrectionCleanupRequired { .. }),
"'{}' must not be treated as a valid archive identity, got {:?}",
archive_like,
state
);
}
}
#[tokio::test]
async fn dirty_target_is_never_complete() {
let mut repo = presynced_repo();
repo.commit("merge", "Merge change: change-a", &["t", "a_tip"])
.tree("merge", &[&archived("change-a")]);
let evidence = FakeEvidence::new(repo, "merge")
.worktree("ws-a", "/wt/a", "a_tip")
.dirty();
let items = vec![item("ws-a", "change-a", "/wt/a")];
match resolve_state::classify_batch(&evidence, &items, "base").await {
BatchState::UnsafeEvidence { reason } => {
assert!(reason.contains("not clean"), "{}", reason)
}
other => panic!("expected incomplete batch, got {:?}", other),
}
}
#[tokio::test]
async fn final_integration_verifier_matches_retry_classification() {
let items = vec![item("ws-a", "change-a", "/wt/a")];
let mut missing = presynced_repo();
missing.tree("t", &[]);
let evidence = FakeEvidence::new(missing, "t").worktree("ws-a", "/wt/a", "a_tip");
let error = resolve_state::verify_final_integration(&evidence, &items, "base")
.await
.expect_err("unintegrated change must fail verification");
assert!(error.contains("change-a"), "{}", error);
let mut historical = FakeRepo::default();
historical
.commit("base", "Base", &[])
.commit("a_tip", "Work", &["base"])
.commit("head", "Follow-up", &["a_tip"]);
let evidence = FakeEvidence::new(historical, "head").worktree("ws-a", "/wt/a", "a_tip");
assert!(
resolve_state::verify_final_integration(&evidence, &items, "base")
.await
.is_ok()
);
let mut false_exact = presynced_repo();
false_exact.commit("other", "Unrelated", &["base"]).commit(
"merge",
"Merge change: change-a",
&["a_tip", "other"],
);
let evidence = FakeEvidence::new(false_exact, "merge").worktree("ws-a", "/wt/a", "a_tip");
let error = resolve_state::verify_final_integration(&evidence, &items, "base")
.await
.expect_err("ancestry fallback must not rescue an invalid exact candidate");
assert!(error.contains("non-first parent"), "{}", error);
}
use crate::parallel::resolve_state::GitResolveEvidence;
async fn git(dir: &Path, args: &[&str]) -> String {
let output = tokio::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.await
.unwrap_or_else(|e| panic!("git {:?}: {}", args, e));
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
async fn write_and_commit(dir: &Path, path: &str, contents: &str, subject: &str) {
let full = dir.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, contents).unwrap();
git(dir, &["add", "-A"]).await;
git(dir, &["commit", "-m", subject]).await;
}
#[tokio::test]
#[cfg_attr(not(feature = "heavy-tests"), ignore)]
async fn sequential_resolve_tracks_the_full_reported_regression() {
let repo = tempfile::TempDir::new().expect("repo tempdir");
let root = repo.path();
git(root, &["init", "-b", "main"]).await;
git(root, &["config", "user.email", "test@example.com"]).await;
git(root, &["config", "user.name", "Test User"]).await;
git(root, &["config", "commit.gpgsign", "false"]).await;
write_and_commit(root, "README.md", "base\n", "Base").await;
let base_revision = git(root, &["rev-parse", "HEAD"]).await;
let worktree = repo.path().parent().unwrap().join(format!(
"{}-ws",
root.file_name().unwrap().to_string_lossy()
));
git(
root,
&[
"worktree",
"add",
"-b",
"ws-change-a",
worktree.to_str().unwrap(),
"HEAD",
],
)
.await;
write_and_commit(
&worktree,
"openspec/changes/archive/2026-08-03-change-a/proposal.md",
"archived\n",
"Archive change-a",
)
.await;
write_and_commit(root, "main.txt", "advanced\n", "Target advance").await;
write_and_commit(
root,
"openspec/changes/change-a/proposal.md",
"live\n",
"Add live change-a",
)
.await;
let items = vec![item("ws-change-a", "change-a", worktree.to_str().unwrap())];
let evidence = GitResolveEvidence::new(root);
let state = resolve_state::classify_batch(&evidence, &items, &base_revision).await;
match &state {
BatchState::PreSyncInvalid {
worktree: reported,
required_target_state,
..
} => {
assert_eq!(
reported.canonicalize().unwrap(),
worktree.canonicalize().unwrap(),
"the supplied archive worktree path must be validated and reported"
);
assert_eq!(
required_target_state,
&git(root, &["rev-parse", "HEAD"]).await
);
}
other => panic!("expected pre-sync to be required, got {:?}", other),
}
assert!(
!state.diagnosis().contains("Missing merge commits"),
"the generic fallback must be gone: {}",
state.diagnosis()
);
git(
&worktree,
&[
"merge",
"--no-ff",
"-m",
"Pre-sync base into change-a",
"main",
],
)
.await;
let state = resolve_state::classify_batch(&evidence, &items, &base_revision).await;
assert!(
matches!(state, BatchState::FinalMergeMissing { .. }),
"valid pre-sync must unblock the final merge, got {:?}",
state
);
let merge = tokio::process::Command::new("git")
.args(["merge", "--no-ff", "--no-commit", "ws-change-a"])
.current_dir(root)
.output()
.await
.expect("git merge");
assert!(
merge.status.success() || merge.status.code() == Some(1),
"merge should either succeed or stop for commit"
);
let head_before_classification = git(root, &["rev-parse", "HEAD"]).await;
let state = resolve_state::classify_batch(&evidence, &items, &base_revision).await;
match &state {
BatchState::TargetMergeUnfinished {
change_id,
requires_live_removal,
..
} => {
assert_eq!(change_id, "change-a");
assert!(
requires_live_removal,
"stage-0 index coexistence must be reported before the final commit"
);
}
other => panic!("expected an unfinished target merge, got {:?}", other),
}
assert_eq!(
git(root, &["rev-parse", "HEAD"]).await,
head_before_classification,
"a conflict-free MERGE_HEAD must not be committed by classification"
);
assert!(
state.diagnosis().contains("Merge change: change-a")
&& !state.diagnosis().contains("Merge changes:"),
"guidance must name the exact per-change subject, never a combined one: {}",
state.diagnosis()
);
git(root, &["commit", "-m", "Merge change: change-a"]).await;
assert!(
matches!(
resolve_state::classify_batch(&evidence, &items, &base_revision).await,
BatchState::ResurrectionCleanupRequired { .. }
),
"committed live/archive coexistence must require forward cleanup"
);
git(root, &["rm", "-r", "-f", "openspec/changes/change-a"]).await;
assert!(
matches!(
resolve_state::classify_batch(&evidence, &items, &base_revision).await,
BatchState::ResurrectionCleanupRequired { .. }
),
"staged-only removal must not be accepted as complete"
);
git(
root,
&["commit", "-m", "Cleanup resurrected change: change-a"],
)
.await;
assert_eq!(
resolve_state::classify_batch(&evidence, &items, &base_revision).await,
BatchState::Complete
);
resolve_state::verify_final_integration(&evidence, &items, &base_revision)
.await
.expect("terminal verification must agree with the retry classifier");
std::fs::write(root.join("stray.txt"), "stray\n").unwrap();
assert!(
matches!(
resolve_state::classify_batch(&evidence, &items, &base_revision).await,
BatchState::UnsafeEvidence { .. }
),
"a dirty target must never read as complete"
);
git(
root,
&["worktree", "remove", worktree.to_str().unwrap(), "--force"],
)
.await;
}