diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index a9d2040c..0147942f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -244,6 +244,12 @@ enum Commands { #[arg(long)] keep_worktree: bool, }, + /// Show the merge queue for inactive worktrees and any branch-to-branch blockers + MergeQueue { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { /// Emit machine-readable JSON instead of the human summary @@ -837,6 +843,14 @@ async fn main() -> Result<()> { } } } + Some(Commands::MergeQueue { json }) => { + let report = session::manager::build_merge_queue(&db)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_merge_queue_human(&report)); + } + } Some(Commands::PruneWorktrees { json }) => { let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?; if json { @@ -1588,6 +1602,59 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome lines.join("\n") } +fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { + let mut lines = Vec::new(); + lines.push(format!( + "Merge queue: {} ready / {} blocked", + report.ready_entries.len(), + report.blocked_entries.len() + )); + + if report.ready_entries.is_empty() { + lines.push("No merge-ready worktrees queued".to_string()); + } else { + lines.push("Ready".to_string()); + for entry in &report.ready_entries { + lines.push(format!( + "- #{} {} [{}] | {} / {} | {}", + entry.queue_position.unwrap_or(0), + entry.session_id, + entry.branch, + entry.project, + entry.task_group, + entry.task + )); + } + } + + if !report.blocked_entries.is_empty() { + lines.push(String::new()); + lines.push("Blocked".to_string()); + for entry in &report.blocked_entries { + lines.push(format!( + "- {} [{}] | {} / {} | {}", + entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action + )); + for blocker in entry.blocked_by.iter().take(2) { + lines.push(format!( + " blocker {} [{}] | {}", + blocker.session_id, blocker.branch, blocker.summary + )); + for conflict in blocker.conflicts.iter().take(3) { + lines.push(format!(" conflict {conflict}")); + } + if let Some(preview) = blocker.conflicting_patch_preview.as_ref() { + for line in preview.lines().take(6) { + lines.push(format!(" {}", line)); + } + } + } + } + } + + lines.join("\n") +} + fn build_otel_export( db: &session::store::StateStore, session_id: Option<&str>, @@ -2535,6 +2602,17 @@ mod tests { } } + #[test] + fn cli_parses_merge_queue_json_flag() { + let cli = Cli::try_parse_from(["ecc", "merge-queue", "--json"]) + .expect("merge-queue --json should parse"); + + match cli.command { + Some(Commands::MergeQueue { json }) => assert!(json), + _ => panic!("expected merge-queue subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -2666,6 +2744,58 @@ mod tests { assert!(text.contains("Cleanup removed worktree and branch")); } + #[test] + fn format_merge_queue_human_reports_ready_and_blocked_entries() { + let text = format_merge_queue_human(&session::manager::MergeQueueReport { + ready_entries: vec![session::manager::MergeQueueEntry { + session_id: "alpha1234".to_string(), + task: "merge alpha".to_string(), + project: "ecc".to_string(), + task_group: "checkout".to_string(), + branch: "ecc/alpha1234".to_string(), + base_branch: "main".to_string(), + state: session::SessionState::Stopped, + worktree_health: worktree::WorktreeHealth::InProgress, + dirty: false, + queue_position: Some(1), + ready_to_merge: true, + blocked_by: Vec::new(), + suggested_action: "merge in queue order #1".to_string(), + }], + blocked_entries: vec![session::manager::MergeQueueEntry { + session_id: "beta5678".to_string(), + task: "merge beta".to_string(), + project: "ecc".to_string(), + task_group: "checkout".to_string(), + branch: "ecc/beta5678".to_string(), + base_branch: "main".to_string(), + state: session::SessionState::Stopped, + worktree_health: worktree::WorktreeHealth::InProgress, + dirty: false, + queue_position: None, + ready_to_merge: false, + blocked_by: vec![session::manager::MergeQueueBlocker { + session_id: "alpha1234".to_string(), + branch: "ecc/alpha1234".to_string(), + state: session::SessionState::Stopped, + conflicts: vec!["README.md".to_string()], + summary: "merge after alpha1234 to avoid branch conflicts".to_string(), + conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()), + blocker_patch_preview: None, + }], + suggested_action: "merge after alpha1234".to_string(), + }], + }); + + assert!(text.contains("Merge queue: 1 ready / 1 blocked")); + assert!(text.contains("Ready")); + assert!(text.contains("#1 alpha1234")); + assert!(text.contains("Blocked")); + assert!(text.contains("beta5678")); + assert!(text.contains("blocker alpha1234")); + assert!(text.contains("conflict README.md")); + } + #[test] fn format_bulk_worktree_merge_human_reports_summary_and_skips() { let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index db059367..a41aaaa7 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -964,6 +964,191 @@ pub async fn prune_inactive_worktrees( }) } +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueBlocker { + pub session_id: String, + pub branch: String, + pub state: SessionState, + pub conflicts: Vec, + pub summary: String, + pub conflicting_patch_preview: Option, + pub blocker_patch_preview: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueEntry { + pub session_id: String, + pub task: String, + pub project: String, + pub task_group: String, + pub branch: String, + pub base_branch: String, + pub state: SessionState, + pub worktree_health: worktree::WorktreeHealth, + pub dirty: bool, + pub queue_position: Option, + pub ready_to_merge: bool, + pub blocked_by: Vec, + pub suggested_action: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueReport { + pub ready_entries: Vec, + pub blocked_entries: Vec, +} + +pub fn build_merge_queue(db: &StateStore) -> Result { + let mut sessions = db + .list_sessions()? + .into_iter() + .filter(|session| session.worktree.is_some()) + .collect::>(); + sessions.sort_by(|left, right| { + merge_queue_priority(left) + .cmp(&merge_queue_priority(right)) + .then_with(|| left.project.cmp(&right.project)) + .then_with(|| left.task_group.cmp(&right.task_group)) + .then_with(|| left.updated_at.cmp(&right.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut entries = Vec::new(); + let mut mergeable_sessions = Vec::::new(); + let mut next_position = 1usize; + + for session in sessions { + let Some(worktree) = session.worktree.clone() else { + continue; + }; + + let worktree_health = worktree::health(&worktree)?; + let dirty = worktree::has_uncommitted_changes(&worktree)?; + let mut blocked_by = Vec::new(); + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) { + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: Vec::new(), + summary: format!("session is still {}", session_state_label(&session.state)), + conflicting_patch_preview: None, + blocker_patch_preview: None, + }); + } else if worktree_health == worktree::WorktreeHealth::Conflicted { + let readiness = worktree::merge_readiness(&worktree)?; + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: readiness.conflicts, + summary: readiness.summary, + conflicting_patch_preview: worktree::diff_patch_preview(&worktree, 18)?, + blocker_patch_preview: None, + }); + } else if dirty { + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: Vec::new(), + summary: "worktree has uncommitted changes".to_string(), + conflicting_patch_preview: worktree::diff_patch_preview(&worktree, 18)?, + blocker_patch_preview: None, + }); + } else { + for blocker in &mergeable_sessions { + let Some(blocker_worktree) = blocker.worktree.as_ref() else { + continue; + }; + let Some(conflict) = + worktree::branch_conflict_preview(&worktree, blocker_worktree, 12)? + else { + continue; + }; + + blocked_by.push(MergeQueueBlocker { + session_id: blocker.id.clone(), + branch: blocker_worktree.branch.clone(), + state: blocker.state.clone(), + conflicts: conflict.conflicts, + summary: format!( + "merge after {} to avoid branch conflicts", + blocker.id + ), + conflicting_patch_preview: conflict.right_patch_preview, + blocker_patch_preview: conflict.left_patch_preview, + }); + } + } + + let ready_to_merge = blocked_by.is_empty(); + let queue_position = if ready_to_merge { + let position = next_position; + next_position += 1; + mergeable_sessions.push(session.clone()); + Some(position) + } else { + None + }; + + let suggested_action = if let Some(position) = queue_position { + format!("merge in queue order #{position}") + } else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) { + blocked_by + .first() + .map(|blocker| blocker.summary.clone()) + .unwrap_or_else(|| "resolve merge blockers".to_string()) + } else { + format!( + "merge after {}", + blocked_by + .iter() + .map(|blocker| blocker.session_id.as_str()) + .collect::>() + .join(", ") + ) + }; + + entries.push(MergeQueueEntry { + session_id: session.id, + task: session.task, + project: session.project, + task_group: session.task_group, + branch: worktree.branch, + base_branch: worktree.base_branch, + state: session.state, + worktree_health, + dirty, + queue_position, + ready_to_merge, + blocked_by, + suggested_action, + }); + } + + let mut ready_entries = entries + .iter() + .filter(|entry| entry.ready_to_merge) + .cloned() + .collect::>(); + ready_entries.sort_by_key(|entry| entry.queue_position.unwrap_or(usize::MAX)); + + let blocked_entries = entries + .into_iter() + .filter(|entry| !entry.ready_to_merge) + .collect::>(); + + Ok(MergeQueueReport { + ready_entries, + blocked_entries, + }) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -1326,6 +1511,14 @@ fn attached_worktree_count(db: &StateStore) -> Result { .count()) } +fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime) { + let active_rank = match session.state { + SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1, + }; + (active_rank, session.updated_at) +} + async fn spawn_session_runner( task: &str, session_id: &str, @@ -3020,6 +3213,97 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> { + let tempdir = TestDir::new("manager-merge-queue")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "alpha\n")?; + run_git(&alpha_worktree.path, ["add", "README.md"])?; + run_git(&alpha_worktree.path, ["commit", "-m", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "beta\n")?; + run_git(&beta_worktree.path, ["add", "README.md"])?; + run_git(&beta_worktree.path, ["commit", "-m", "beta change"])?; + + let gamma_worktree = worktree::create_for_session_in_repo("gamma", &cfg, &repo_root)?; + fs::write(gamma_worktree.path.join("src.txt"), "gamma\n")?; + run_git(&gamma_worktree.path, ["add", "src.txt"])?; + run_git(&gamma_worktree.path, ["commit", "-m", "gamma change"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(alpha_worktree), + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(beta_worktree), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "gamma".to_string(), + task: "gamma merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: gamma_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(gamma_worktree), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let queue = build_merge_queue(&db)?; + assert_eq!(queue.ready_entries.len(), 2); + assert_eq!(queue.ready_entries[0].session_id, "alpha"); + assert_eq!(queue.ready_entries[0].queue_position, Some(1)); + assert_eq!(queue.ready_entries[1].session_id, "gamma"); + assert_eq!(queue.ready_entries[1].queue_position, Some(2)); + + assert_eq!(queue.blocked_entries.len(), 1); + let blocked = &queue.blocked_entries[0]; + assert_eq!(blocked.session_id, "beta"); + assert_eq!(blocked.blocked_by.len(), 1); + assert_eq!(blocked.blocked_by[0].session_id, "alpha"); + assert!(blocked.blocked_by[0] + .conflicts + .contains(&"README.md".to_string())); + assert!(blocked.suggested_action.contains("merge after alpha")); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ad8e583a..f207f0fd 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4371,6 +4371,35 @@ impl Dashboard { lines.push(format!("- conflict {conflict}")); } } + if let Ok(merge_queue) = manager::build_merge_queue(&self.db) { + let entry = merge_queue + .ready_entries + .iter() + .chain(merge_queue.blocked_entries.iter()) + .find(|entry| entry.session_id == session.id); + if let Some(entry) = entry { + lines.push("Merge queue".to_string()); + if let Some(position) = entry.queue_position { + lines.push(format!( + "- ready #{} | {}", + position, entry.suggested_action + )); + } else { + lines.push(format!("- blocked | {}", entry.suggested_action)); + } + for blocker in entry.blocked_by.iter().take(2) { + lines.push(format!( + " blocker {} [{}] | {}", + format_session_id(&blocker.session_id), + blocker.branch, + blocker.summary + )); + for conflict in blocker.conflicts.iter().take(3) { + lines.push(format!(" conflict {conflict}")); + } + } + } + } } lines.push(format!( diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 38f43add..6287e811 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -19,7 +19,7 @@ pub struct MergeReadiness { pub conflicts: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] pub enum WorktreeHealth { Clear, InProgress, @@ -33,6 +33,15 @@ pub struct MergeOutcome { pub already_up_to_date: bool, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BranchConflictPreview { + pub left_branch: String, + pub right_branch: String, + pub conflicts: Vec, + pub left_patch_preview: Option, + pub right_patch_preview: Option, +} + /// Create a new git worktree for an agent session. pub fn create_for_session(session_id: &str, cfg: &Config) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve repository root")?; @@ -255,15 +264,45 @@ pub fn diff_patch_preview(worktree: &WorktreeInfo, max_lines: usize) -> Result Result { + let mut readiness = merge_readiness_for_branches( + &base_checkout_path(worktree)?, + &worktree.base_branch, + &worktree.branch, + )?; + readiness.summary = match readiness.status { + MergeReadinessStatus::Ready => format!("Merge ready into {}", worktree.base_branch), + MergeReadinessStatus::Conflicted => { + let conflict_summary = readiness + .conflicts + .iter() + .take(3) + .cloned() + .collect::>() + .join(", "); + let overflow = readiness.conflicts.len().saturating_sub(3); + let detail = if overflow > 0 { + format!("{conflict_summary}, +{overflow} more") + } else { + conflict_summary + }; + format!( + "Merge blocked by {} conflict(s): {detail}", + readiness.conflicts.len() + ) + } + }; + Ok(readiness) +} + +pub fn merge_readiness_for_branches( + repo_root: &Path, + left_branch: &str, + right_branch: &str, +) -> Result { let output = Command::new("git") .arg("-C") - .arg(&worktree.path) - .args([ - "merge-tree", - "--write-tree", - &worktree.base_branch, - &worktree.branch, - ]) + .arg(repo_root) + .args(["merge-tree", "--write-tree", left_branch, right_branch]) .output() .context("Failed to generate merge readiness preview")?; @@ -280,7 +319,7 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { if output.status.success() { return Ok(MergeReadiness { status: MergeReadinessStatus::Ready, - summary: format!("Merge ready into {}", worktree.base_branch), + summary: format!("Merge ready: {right_branch} into {left_branch}"), conflicts: Vec::new(), }); } @@ -301,7 +340,10 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { return Ok(MergeReadiness { status: MergeReadinessStatus::Conflicted, - summary: format!("Merge blocked by {} conflict(s): {detail}", conflicts.len()), + summary: format!( + "Merge blocked between {left_branch} and {right_branch} by {} conflict(s): {detail}", + conflicts.len() + ), conflicts, }); } @@ -310,6 +352,30 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { anyhow::bail!("git merge-tree failed: {stderr}"); } +pub fn branch_conflict_preview( + left: &WorktreeInfo, + right: &WorktreeInfo, + max_lines: usize, +) -> Result> { + if left.base_branch != right.base_branch { + return Ok(None); + } + + let repo_root = base_checkout_path(left)?; + let readiness = merge_readiness_for_branches(&repo_root, &left.branch, &right.branch)?; + if readiness.status != MergeReadinessStatus::Conflicted { + return Ok(None); + } + + Ok(Some(BranchConflictPreview { + left_branch: left.branch.clone(), + right_branch: right.branch.clone(), + conflicts: readiness.conflicts.clone(), + left_patch_preview: diff_patch_preview_for_paths(left, &readiness.conflicts, max_lines)?, + right_patch_preview: diff_patch_preview_for_paths(right, &readiness.conflicts, max_lines)?, + })) +} + pub fn health(worktree: &WorktreeInfo) -> Result { let merge_readiness = merge_readiness(worktree)?; if merge_readiness.status == MergeReadinessStatus::Conflicted { @@ -462,6 +528,79 @@ fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + if paths.is_empty() { + return Ok(Vec::new()); + } + + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--stat", "--patch", "--find-renames"]); + command.args(extra_args); + command.arg("--"); + for path in paths { + command.arg(path); + } + + let output = command + .output() + .context("Failed to generate filtered worktree patch preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Filtered worktree patch preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +pub fn diff_patch_preview_for_paths( + worktree: &WorktreeInfo, + paths: &[String], + max_lines: usize, +) -> Result> { + if paths.is_empty() { + return Ok(None); + } + + let mut remaining = max_lines.max(1); + let mut sections = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_patch_lines_for_paths(&worktree.path, &[&base_ref], paths)?; + if !committed.is_empty() && remaining > 0 { + let taken = take_preview_lines(&committed, &mut remaining); + sections.push(format!( + "--- Branch diff vs {} ---\n{}", + worktree.base_branch, + taken.join("\n") + )); + } + + let working = git_diff_patch_lines_for_paths(&worktree.path, &[], paths)?; + if !working.is_empty() && remaining > 0 { + let taken = take_preview_lines(&working, &mut remaining); + sections.push(format!("--- Working tree diff ---\n{}", taken.join("\n"))); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(sections.join("\n\n"))) + } +} + fn git_status_short(worktree_path: &Path) -> Result> { let output = Command::new("git") .arg("-C") @@ -901,4 +1040,81 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { + let root = std::env::temp_dir() + .join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let left_dir = root.join("wt-left"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/left", + left_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(left_dir.join("README.md"), "left\n")?; + run_git(&left_dir, &["add", "README.md"])?; + run_git(&left_dir, &["commit", "-m", "left change"])?; + + let right_dir = root.join("wt-right"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/right", + right_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(right_dir.join("README.md"), "right\n")?; + run_git(&right_dir, &["add", "README.md"])?; + run_git(&right_dir, &["commit", "-m", "right change"])?; + + let left = WorktreeInfo { + path: left_dir.clone(), + branch: "ecc/left".to_string(), + base_branch: "main".to_string(), + }; + let right = WorktreeInfo { + path: right_dir.clone(), + branch: "ecc/right".to_string(), + base_branch: "main".to_string(), + }; + + let preview = branch_conflict_preview(&left, &right, 12)? + .expect("expected branch conflict preview"); + assert_eq!(preview.conflicts, vec!["README.md".to_string()]); + assert!(preview + .left_patch_preview + .as_ref() + .is_some_and(|preview| preview.contains("README.md"))); + assert!(preview + .right_patch_preview + .as_ref() + .is_some_and(|preview| preview.contains("README.md"))); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&left_dir) + .output(); + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&right_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } }