From dd14888f5fd9c3f6d75d586790b90d652162ac67 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:54:31 -0700 Subject: [PATCH] feat: add ecc2 worktree merge readiness --- ecc2/src/tui/dashboard.rs | 18 ++++ ecc2/src/worktree/mod.rs | 179 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 05540ae2..826a4ac2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -55,6 +55,7 @@ pub struct Dashboard { selected_diff_summary: Option, selected_diff_preview: Vec, selected_diff_patch: Option, + selected_merge_readiness: Option, output_mode: OutputMode, selected_pane: Pane, selected_session: usize, @@ -170,6 +171,7 @@ impl Dashboard { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session: 0, @@ -1321,6 +1323,8 @@ impl Dashboard { .unwrap_or_default(); self.selected_diff_patch = worktree .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); + self.selected_merge_readiness = worktree + .and_then(|worktree| worktree::merge_readiness(worktree).ok()); if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { self.output_mode = OutputMode::SessionOutput; } @@ -1721,6 +1725,12 @@ impl Dashboard { lines.push(format!("- {entry}")); } } + if let Some(merge_readiness) = self.selected_merge_readiness.as_ref() { + lines.push(merge_readiness.summary.clone()); + for conflict in merge_readiness.conflicts.iter().take(3) { + lines.push(format!("- conflict {conflict}")); + } + } } lines.push(format!( @@ -2294,6 +2304,11 @@ mod tests { "Branch M src/main.rs".to_string(), "Working ?? notes.txt".to_string(), ]; + dashboard.selected_merge_readiness = Some(worktree::MergeReadiness { + status: worktree::MergeReadinessStatus::Conflicted, + summary: "Merge blocked by 1 conflict(s): src/main.rs".to_string(), + conflicts: vec!["src/main.rs".to_string()], + }); let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Branch ecc/focus | Base main")); @@ -2302,6 +2317,8 @@ mod tests { assert!(text.contains("Changed files")); assert!(text.contains("- Branch M src/main.rs")); assert!(text.contains("- Working ?? notes.txt")); + assert!(text.contains("Merge blocked by 1 conflict(s): src/main.rs")); + assert!(text.contains("- conflict src/main.rs")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -3171,6 +3188,7 @@ mod tests { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index de7edf4a..72cefa8b 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -5,6 +5,19 @@ use std::process::Command; use crate::config::Config; use crate::session::WorktreeInfo; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeReadinessStatus { + Ready, + Conflicted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeReadiness { + pub status: MergeReadinessStatus, + pub summary: String, + pub conflicts: Vec, +} + /// 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")?; @@ -164,6 +177,57 @@ pub fn diff_patch_preview(worktree: &WorktreeInfo, max_lines: usize) -> Result Result { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["merge-tree", "--write-tree", &worktree.base_branch, &worktree.branch]) + .output() + .context("Failed to generate merge readiness preview")?; + + let merged_output = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let conflicts = merged_output + .lines() + .filter_map(parse_merge_conflict_path) + .collect::>(); + + if output.status.success() { + return Ok(MergeReadiness { + status: MergeReadinessStatus::Ready, + summary: format!("Merge ready into {}", worktree.base_branch), + conflicts: Vec::new(), + }); + } + + if !conflicts.is_empty() { + let conflict_summary = conflicts + .iter() + .take(3) + .cloned() + .collect::>() + .join(", "); + let overflow = conflicts.len().saturating_sub(3); + let detail = if overflow > 0 { + format!("{conflict_summary}, +{overflow} more") + } else { + conflict_summary + }; + + return Ok(MergeReadiness { + status: MergeReadinessStatus::Conflicted, + summary: format!("Merge blocked by {} conflict(s): {detail}", conflicts.len()), + conflicts, + }); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git merge-tree failed: {stderr}"); +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -280,6 +344,18 @@ fn take_preview_lines(lines: &[String], remaining: &mut usize) -> Vec { taken } +fn parse_merge_conflict_path(line: &str) -> Option { + if !line.contains("CONFLICT") { + return None; + } + + line.split(" in ") + .nth(1) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -474,4 +550,107 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn merge_readiness_reports_ready_worktree() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("src.txt"), "branch only\n")?; + run_git(&worktree_dir, &["add", "src.txt"])?; + run_git(&worktree_dir, &["commit", "-m", "branch file"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let readiness = merge_readiness(&info)?; + assert_eq!(readiness.status, MergeReadinessStatus::Ready); + assert!(readiness.summary.contains("Merge ready into main")); + assert!(readiness.conflicts.is_empty()); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn merge_readiness_reports_conflicted_worktree() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("README.md"), "hello\nbranch\n")?; + run_git(&worktree_dir, &["commit", "-am", "branch change"])?; + fs::write(repo.join("README.md"), "hello\nmain\n")?; + run_git(&repo, &["commit", "-am", "main change"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let readiness = merge_readiness(&info)?; + assert_eq!(readiness.status, MergeReadinessStatus::Conflicted); + assert!(readiness.summary.contains("Merge blocked by 1 conflict")); + assert_eq!(readiness.conflicts, vec!["README.md".to_string()]); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } }