From e6460534e3d70790273bb5ba4e478f3ffb58646d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:04:52 -0700 Subject: [PATCH] feat: add ecc2 bulk worktree merge actions --- ecc2/src/main.rs | 143 +++++++++++++++++++++++++-- ecc2/src/session/manager.rs | 192 +++++++++++++++++++++++++++++++++++- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 125 ++++++++++++++++++++++- ecc2/src/worktree/mod.rs | 6 +- 5 files changed, 457 insertions(+), 10 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 008b2094..e70ee222 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -207,6 +207,9 @@ enum Commands { MergeWorktree { /// Session ID or alias session_id: Option, + /// Merge all ready inactive worktrees + #[arg(long)] + all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, @@ -703,17 +706,36 @@ async fn main() -> Result<()> { } Some(Commands::MergeWorktree { session_id, + all, json, keep_worktree, }) => { - let id = session_id.unwrap_or_else(|| "latest".to_string()); - let resolved_id = resolve_session_id(&db, &id)?; - let outcome = - session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree).await?; - if json { - println!("{}", serde_json::to_string_pretty(&outcome)?); + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "merge-worktree does not accept a session ID when --all is set" + )); + } + if all { + let outcome = session::manager::merge_ready_worktrees(&db, !keep_worktree).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_bulk_worktree_merge_human(&outcome)); + } } else { - println!("{}", format_worktree_merge_human(&outcome)); + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let outcome = session::manager::merge_session_worktree( + &db, + &resolved_id, + !keep_worktree, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_worktree_merge_human(&outcome)); + } } } Some(Commands::PruneWorktrees { json }) => { @@ -1102,6 +1124,62 @@ fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) lines.join("\n") } +fn format_bulk_worktree_merge_human(outcome: &session::manager::WorktreeBulkMergeOutcome) -> String { + let mut lines = Vec::new(); + lines.push(format!( + "Merged {} ready worktree(s)", + outcome.merged.len() + )); + + for merged in &outcome.merged { + lines.push(format!( + "- merged {} -> {} for {}{}", + merged.branch, + merged.base_branch, + short_session(&merged.session_id), + if merged.already_up_to_date { + " (already up to date)" + } else { + "" + } + )); + } + + if !outcome.active_with_worktree_ids.is_empty() { + lines.push(format!( + "Skipped {} active worktree session(s)", + outcome.active_with_worktree_ids.len() + )); + } + if !outcome.conflicted_session_ids.is_empty() { + lines.push(format!( + "Skipped {} conflicted worktree(s)", + outcome.conflicted_session_ids.len() + )); + } + if !outcome.dirty_worktree_ids.is_empty() { + lines.push(format!( + "Skipped {} dirty worktree(s)", + outcome.dirty_worktree_ids.len() + )); + } + if !outcome.failures.is_empty() { + lines.push(format!( + "Encountered {} merge failure(s)", + outcome.failures.len() + )); + for failure in &outcome.failures { + lines.push(format!( + "- failed {}: {}", + short_session(&failure.session_id), + failure.reason + )); + } + } + + lines.join("\n") +} + fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { report.check_exit_code } @@ -1575,10 +1653,12 @@ mod tests { match cli.command { Some(Commands::MergeWorktree { session_id, + all, json, keep_worktree, }) => { assert_eq!(session_id.as_deref(), Some("deadbeef")); + assert!(!all); assert!(json); assert!(keep_worktree); } @@ -1586,6 +1666,27 @@ mod tests { } } + #[test] + fn cli_parses_merge_worktree_all_flags() { + let cli = Cli::try_parse_from(["ecc", "merge-worktree", "--all", "--json"]) + .expect("merge-worktree --all --json should parse"); + + match cli.command { + Some(Commands::MergeWorktree { + session_id, + all, + json, + keep_worktree, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert!(json); + assert!(!keep_worktree); + } + _ => panic!("expected merge-worktree subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -1649,6 +1750,34 @@ mod tests { assert!(text.contains("Cleanup removed worktree and branch")); } + #[test] + fn format_bulk_worktree_merge_human_reports_summary_and_skips() { + let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome { + merged: vec![session::manager::WorktreeMergeOutcome { + session_id: "deadbeefcafefeed".to_string(), + branch: "ecc/deadbeefcafefeed".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + cleaned_worktree: true, + }], + active_with_worktree_ids: vec!["running12345678".to_string()], + conflicted_session_ids: vec!["conflict123456".to_string()], + dirty_worktree_ids: vec!["dirty123456789".to_string()], + failures: vec![session::manager::WorktreeMergeFailure { + session_id: "fail1234567890".to_string(), + reason: "base branch not checked out".to_string(), + }], + }); + + assert!(text.contains("Merged 1 ready worktree(s)")); + assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef")); + assert!(text.contains("Skipped 1 active worktree session(s)")); + assert!(text.contains("Skipped 1 conflicted worktree(s)")); + assert!(text.contains("Skipped 1 dirty worktree(s)")); + assert!(text.contains("Encountered 1 merge failure(s)")); + assert!(text.contains("- failed fail1234: base branch not checked out")); + } + #[test] fn format_worktree_status_human_handles_missing_worktree() { let report = WorktreeStatusReport { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 24f2ab0b..1897f6ad 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -626,7 +626,10 @@ pub async fn merge_session_worktree( ) -> Result { let session = resolve_session(db, id)?; - if matches!(session.state, SessionState::Pending | SessionState::Running) { + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { anyhow::bail!( "Cannot merge active session {} while it is {}", session.id, @@ -654,6 +657,95 @@ pub async fn merge_session_worktree( }) } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeMergeFailure { + pub session_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeBulkMergeOutcome { + pub merged: Vec, + pub active_with_worktree_ids: Vec, + pub conflicted_session_ids: Vec, + pub dirty_worktree_ids: Vec, + pub failures: Vec, +} + +pub async fn merge_ready_worktrees( + db: &StateStore, + cleanup_worktree: bool, +) -> Result { + let sessions = db.list_sessions()?; + let mut merged = Vec::new(); + let mut active_with_worktree_ids = Vec::new(); + let mut conflicted_session_ids = Vec::new(); + let mut dirty_worktree_ids = Vec::new(); + let mut failures = Vec::new(); + + for session in sessions { + let Some(worktree) = session.worktree.clone() else { + continue; + }; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { + active_with_worktree_ids.push(session.id); + continue; + } + + match crate::worktree::merge_readiness(&worktree) { + Ok(readiness) + if readiness.status == crate::worktree::MergeReadinessStatus::Conflicted => + { + conflicted_session_ids.push(session.id); + continue; + } + Ok(_) => {} + Err(error) => { + failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }); + continue; + } + } + + match crate::worktree::has_uncommitted_changes(&worktree) { + Ok(true) => { + dirty_worktree_ids.push(session.id); + continue; + } + Ok(false) => {} + Err(error) => { + failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }); + continue; + } + } + + match merge_session_worktree(db, &session.id, cleanup_worktree).await { + Ok(outcome) => merged.push(outcome), + Err(error) => failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }), + } + } + + Ok(WorktreeBulkMergeOutcome { + merged, + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + failures, + }) +} + #[derive(Debug, Clone, Serialize)] pub struct WorktreePruneOutcome { pub cleaned_session_ids: Vec, @@ -1960,6 +2052,104 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_ready_worktrees_merges_ready_sessions_and_skips_active_and_dirty() -> Result<()> + { + let tempdir = TestDir::new("manager-merge-ready-worktrees")?; + 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 merged_worktree = + crate::worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; + fs::write(merged_worktree.path.join("merged.txt"), "bulk merge\n")?; + run_git(&merged_worktree.path, ["add", "merged.txt"])?; + run_git(&merged_worktree.path, ["commit", "-qm", "merge ready"])?; + db.insert_session(&Session { + id: "merge-ready".to_string(), + task: "merge me".to_string(), + agent_type: "claude".to_string(), + working_dir: merged_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(merged_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let active_worktree = + crate::worktree::create_for_session_in_repo("active-worktree", &cfg, &repo_root)?; + db.insert_session(&Session { + id: "active-worktree".to_string(), + task: "still running".to_string(), + agent_type: "claude".to_string(), + working_dir: active_worktree.path.clone(), + state: SessionState::Running, + pid: Some(12345), + worktree: Some(active_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dirty_worktree = + crate::worktree::create_for_session_in_repo("dirty-worktree", &cfg, &repo_root)?; + fs::write(dirty_worktree.path.join("dirty.txt"), "not committed yet\n")?; + db.insert_session(&Session { + id: "dirty-worktree".to_string(), + task: "needs commit".to_string(), + agent_type: "claude".to_string(), + working_dir: dirty_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(dirty_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let outcome = merge_ready_worktrees(&db, true).await?; + + assert_eq!(outcome.merged.len(), 1); + assert_eq!(outcome.merged[0].session_id, "merge-ready"); + assert_eq!(outcome.active_with_worktree_ids, vec!["active-worktree".to_string()]); + assert_eq!(outcome.dirty_worktree_ids, vec!["dirty-worktree".to_string()]); + assert!(outcome.conflicted_session_ids.is_empty()); + assert!(outcome.failures.is_empty()); + + assert_eq!( + fs::read_to_string(repo_root.join("merged.txt"))?, + "bulk merge\n" + ); + assert!( + db.get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none() + ); + assert!( + db.get_session("active-worktree")? + .context("active session should still exist")? + .worktree + .is_some() + ); + assert!( + db.get_session("dirty-worktree")? + .context("dirty session should still exist")? + .worktree + .is_some() + ); + assert!(!merged_worktree.path.exists()); + assert!(active_worktree.path.exists()); + assert!(dirty_worktree.path.exists()); + + 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/app.rs b/ecc2/src/tui/app.rs index de9e1c7c..792f4f31 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,6 +47,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, + (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 3fda8541..40ba2dc3 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -462,7 +462,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", + " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff [m]erge merge ready [M] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", self.layout_label() ); let text = if let Some(note) = self.operator_note.as_ref() { @@ -515,6 +515,7 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", " m Merge selected ready worktree into base and clean it up", + " M Merge all ready inactive worktrees and clean them up", " p Toggle daemon auto-dispatch policy and persist config", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -1151,6 +1152,51 @@ impl Dashboard { )); } + pub async fn merge_ready_worktrees(&mut self) { + match manager::merge_ready_worktrees(&self.db, true).await { + Ok(outcome) => { + self.refresh(); + if outcome.merged.is_empty() + && outcome.active_with_worktree_ids.is_empty() + && outcome.conflicted_session_ids.is_empty() + && outcome.dirty_worktree_ids.is_empty() + && outcome.failures.is_empty() + { + self.set_operator_note("no ready worktrees to merge".to_string()); + return; + } + + let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())]; + if !outcome.active_with_worktree_ids.is_empty() { + parts.push(format!( + "skipped {} active", + outcome.active_with_worktree_ids.len() + )); + } + if !outcome.conflicted_session_ids.is_empty() { + parts.push(format!( + "skipped {} conflicted", + outcome.conflicted_session_ids.len() + )); + } + if !outcome.dirty_worktree_ids.is_empty() { + parts.push(format!( + "skipped {} dirty", + outcome.dirty_worktree_ids.len() + )); + } + if !outcome.failures.is_empty() { + parts.push(format!("{} failed", outcome.failures.len())); + } + self.set_operator_note(parts.join("; ")); + } + Err(error) => { + tracing::warn!("Failed to merge ready worktrees: {error}"); + self.set_operator_note(format!("merge ready worktrees failed: {error}")); + } + } + } + pub async fn prune_inactive_worktrees(&mut self) { match manager::prune_inactive_worktrees(&self.db).await { Ok(outcome) => { @@ -3335,6 +3381,83 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_ready_worktrees_sets_operator_note_with_skip_summary() -> Result<()> { + let tempdir = + std::env::temp_dir().join(format!("dashboard-merge-ready-{}", Uuid::new_v4())); + let repo_root = tempdir.join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(&tempdir); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let merged_worktree = worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; + std::fs::write(merged_worktree.path.join("merged.txt"), "dashboard bulk merge\n")?; + Command::new("git") + .arg("-C") + .arg(&merged_worktree.path) + .args(["add", "merged.txt"]) + .status()?; + Command::new("git") + .arg("-C") + .arg(&merged_worktree.path) + .args(["commit", "-qm", "dashboard bulk merge"]) + .status()?; + db.insert_session(&Session { + id: "merge-ready".to_string(), + task: "merge via dashboard".to_string(), + agent_type: "claude".to_string(), + working_dir: merged_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(merged_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let active_worktree = + worktree::create_for_session_in_repo("active-ready", &cfg, &repo_root)?; + db.insert_session(&Session { + id: "active-ready".to_string(), + task: "still active".to_string(), + agent_type: "claude".to_string(), + working_dir: active_worktree.path.clone(), + state: SessionState::Running, + pid: Some(999), + worktree: Some(active_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.merge_ready_worktrees().await; + + let note = dashboard + .operator_note + .clone() + .context("operator note should be set")?; + assert!(note.contains("merged 1 ready worktree(s)")); + assert!(note.contains("skipped 1 active")); + assert!( + dashboard + .db + .get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none() + ); + assert_eq!( + std::fs::read_to_string(repo_root.join("merged.txt"))?, + "dashboard bulk merge\n" + ); + + let _ = std::fs::remove_dir_all(&tempdir); + Ok(()) + } + #[tokio::test] async fn delete_selected_session_removes_inactive_session() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 574cf22d..c53a57e0 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -297,13 +297,17 @@ pub fn health(worktree: &WorktreeInfo) -> Result { } } +pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { + Ok(!git_status_short(&worktree.path)?.is_empty()) +} + pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { let readiness = merge_readiness(worktree)?; if readiness.status == MergeReadinessStatus::Conflicted { anyhow::bail!(readiness.summary); } - if !git_status_short(&worktree.path)?.is_empty() { + if has_uncommitted_changes(worktree)? { anyhow::bail!( "Worktree {} has uncommitted changes; commit or discard them before merging", worktree.branch