From d8c8178f92f48161857a59818f0b3d7ed088da81 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:17:45 -0700 Subject: [PATCH] feat: add ecc2 worktree conflict protocol --- ecc2/src/main.rs | 286 ++++++++++++++++++++++++++++++++++++++ ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 135 +++++++++++++++++- 3 files changed, 417 insertions(+), 5 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index e70ee222..6a5b3cd6 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -203,6 +203,20 @@ enum Commands { #[arg(long)] check: bool, }, + /// Show conflict-resolution protocol for a worktree + WorktreeResolution { + /// Session ID or alias + session_id: Option, + /// Show conflict protocol for all conflicted worktrees + #[arg(long)] + all: bool, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + /// Return a non-zero exit code when conflicted worktrees are present + #[arg(long)] + check: bool, + }, /// Merge a session worktree branch into its base branch MergeWorktree { /// Session ID or alias @@ -704,6 +718,46 @@ async fn main() -> Result<()> { std::process::exit(worktree_status_reports_exit_code(&reports)); } } + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "worktree-resolution does not accept a session ID when --all is set" + )); + } + let reports = if all { + session::manager::list_sessions(&db)? + .into_iter() + .map(|session| build_worktree_resolution_report(&session)) + .collect::>>()? + .into_iter() + .filter(|report| report.conflicted) + .collect::>() + } else { + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let session = db + .get_session(&resolved_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; + vec![build_worktree_resolution_report(&session)?] + }; + if json { + if all { + println!("{}", serde_json::to_string_pretty(&reports)?); + } else { + println!("{}", serde_json::to_string_pretty(&reports[0])?); + } + } else { + println!("{}", format_worktree_resolution_reports_human(&reports)); + } + if check { + std::process::exit(worktree_resolution_reports_exit_code(&reports)); + } + } Some(Commands::MergeWorktree { session_id, all, @@ -987,6 +1041,22 @@ struct WorktreeStatusReport { merge_readiness: Option, } +#[derive(Debug, Clone, Serialize)] +struct WorktreeResolutionReport { + session_id: String, + task: String, + session_state: String, + attached: bool, + conflicted: bool, + check_exit_code: i32, + path: Option, + branch: Option, + base_branch: Option, + summary: String, + conflicts: Vec, + resolution_steps: Vec, +} + fn build_worktree_status_report(session: &session::Session, include_patch: bool) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeStatusReport { @@ -1047,6 +1117,55 @@ fn build_worktree_status_report(session: &session::Session, include_patch: bool) }) } +fn build_worktree_resolution_report(session: &session::Session) -> Result { + let Some(worktree) = session.worktree.as_ref() else { + return Ok(WorktreeResolutionReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: false, + conflicted: false, + check_exit_code: 0, + path: None, + branch: None, + base_branch: None, + summary: "No worktree attached".to_string(), + conflicts: Vec::new(), + resolution_steps: Vec::new(), + }); + }; + + let merge_readiness = worktree::merge_readiness(worktree)?; + let conflicted = merge_readiness.status == worktree::MergeReadinessStatus::Conflicted; + let resolution_steps = if conflicted { + vec![ + format!("Inspect current patch: ecc worktree-status {} --patch", session.id), + format!("Open worktree: cd {}", worktree.path.display()), + "Resolve conflicts and stage files: git add ".to_string(), + format!("Commit the resolution on {}: git commit", worktree.branch), + format!("Re-check readiness: ecc worktree-status {} --check", session.id), + format!("Merge when clear: ecc merge-worktree {}", session.id), + ] + } else { + Vec::new() + }; + + Ok(WorktreeResolutionReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: true, + conflicted, + check_exit_code: if conflicted { 2 } else { 0 }, + path: Some(worktree.path.display().to_string()), + branch: Some(worktree.branch.clone()), + base_branch: Some(worktree.base_branch.clone()), + summary: merge_readiness.summary, + conflicts: merge_readiness.conflicts, + resolution_steps, + }) +} + fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { let mut lines = vec![format!( "Worktree status for {} [{}]", @@ -1102,6 +1221,58 @@ fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> Str .join("\n\n") } +fn format_worktree_resolution_human(report: &WorktreeResolutionReport) -> String { + let mut lines = vec![format!( + "Worktree resolution for {} [{}]", + short_session(&report.session_id), + report.session_state + )]; + lines.push(format!("Task {}", report.task)); + + if !report.attached { + lines.push(report.summary.clone()); + return lines.join("\n"); + } + + if let Some(path) = report.path.as_ref() { + lines.push(format!("Path {path}")); + } + if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { + lines.push(format!("Branch {branch} (base {base_branch})")); + } + lines.push(report.summary.clone()); + + if !report.conflicts.is_empty() { + lines.push("Conflicts".to_string()); + for conflict in &report.conflicts { + lines.push(format!("- {conflict}")); + } + } + + if report.resolution_steps.is_empty() { + lines.push("No conflict-resolution steps required".to_string()); + } else { + lines.push("Resolution steps".to_string()); + for (index, step) in report.resolution_steps.iter().enumerate() { + lines.push(format!("{}. {step}", index + 1)); + } + } + + lines.join("\n") +} + +fn format_worktree_resolution_reports_human(reports: &[WorktreeResolutionReport]) -> String { + if reports.is_empty() { + return "No conflicted worktrees found".to_string(); + } + + reports + .iter() + .map(format_worktree_resolution_human) + .collect::>() + .join("\n\n") +} + fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) -> String { let mut lines = vec![format!( "Merged worktree for {}", @@ -1192,6 +1363,14 @@ fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { .unwrap_or(0) } +fn worktree_resolution_reports_exit_code(reports: &[WorktreeResolutionReport]) -> i32 { + reports + .iter() + .map(|report| report.check_exit_code) + .max() + .unwrap_or(0) +} + fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome) -> String { let mut lines = Vec::new(); @@ -1626,6 +1805,48 @@ mod tests { } } + #[test] + fn cli_parses_worktree_resolution_flags() { + let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "planner", "--json", "--check"]) + .expect("worktree-resolution flags should parse"); + + match cli.command { + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + assert_eq!(session_id.as_deref(), Some("planner")); + assert!(!all); + assert!(json); + assert!(check); + } + _ => panic!("expected worktree-resolution subcommand"), + } + } + + #[test] + fn cli_parses_worktree_resolution_all_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "--all"]) + .expect("worktree-resolution --all should parse"); + + match cli.command { + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert!(!json); + assert!(!check); + } + _ => panic!("expected worktree-resolution subcommand"), + } + } + #[test] fn cli_parses_prune_worktrees_json_flag() { let cli = Cli::try_parse_from(["ecc", "prune-worktrees", "--json"]) @@ -1721,6 +1942,71 @@ mod tests { assert!(text.contains("--- Branch diff vs main ---")); } + #[test] + fn format_worktree_resolution_human_includes_protocol_steps() { + let report = WorktreeResolutionReport { + session_id: "deadbeefcafefeed".to_string(), + task: "Resolve merge conflict".to_string(), + session_state: "stopped".to_string(), + attached: true, + conflicted: true, + check_exit_code: 2, + path: Some("/tmp/ecc/wt-1".to_string()), + branch: Some("ecc/deadbeefcafefeed".to_string()), + base_branch: Some("main".to_string()), + summary: "Merge blocked by 1 conflict(s): README.md".to_string(), + conflicts: vec!["README.md".to_string()], + resolution_steps: vec![ + "Inspect current patch: ecc worktree-status deadbeefcafefeed --patch".to_string(), + "Open worktree: cd /tmp/ecc/wt-1".to_string(), + "Resolve conflicts and stage files: git add ".to_string(), + ], + }; + + let text = format_worktree_resolution_human(&report); + assert!(text.contains("Worktree resolution for deadbeef [stopped]")); + assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); + assert!(text.contains("Conflicts")); + assert!(text.contains("- README.md")); + assert!(text.contains("Resolution steps")); + assert!(text.contains("1. Inspect current patch")); + } + + #[test] + fn worktree_resolution_reports_exit_code_tracks_conflicts() { + let clear = WorktreeResolutionReport { + session_id: "clear".to_string(), + task: "ok".to_string(), + session_state: "stopped".to_string(), + attached: false, + conflicted: false, + check_exit_code: 0, + path: None, + branch: None, + base_branch: None, + summary: "No worktree attached".to_string(), + conflicts: Vec::new(), + resolution_steps: Vec::new(), + }; + let conflicted = WorktreeResolutionReport { + session_id: "conflicted".to_string(), + task: "resolve".to_string(), + session_state: "failed".to_string(), + attached: true, + conflicted: true, + check_exit_code: 2, + path: Some("/tmp/ecc/wt-2".to_string()), + branch: Some("ecc/conflicted".to_string()), + base_branch: Some("main".to_string()), + summary: "Merge blocked by 1 conflict(s): src/lib.rs".to_string(), + conflicts: vec!["src/lib.rs".to_string()], + resolution_steps: vec!["Inspect current patch".to_string()], + }; + + assert_eq!(worktree_resolution_reports_exit_code(&[clear]), 0); + assert_eq!(worktree_resolution_reports_exit_code(&[conflicted]), 2); + } + #[test] fn format_prune_worktrees_human_reports_cleaned_and_active_sessions() { let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 34f3bc17..8b8e7cac 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -46,6 +46,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_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(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 58aa2d29..1282fb0a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -54,6 +54,7 @@ pub struct Dashboard { selected_diff_summary: Option, selected_diff_preview: Vec, selected_diff_patch: Option, + selected_conflict_protocol: Option, selected_merge_readiness: Option, output_mode: OutputMode, selected_pane: Pane, @@ -94,6 +95,7 @@ enum Pane { enum OutputMode { SessionOutput, WorktreeDiff, + ConflictProtocol, } #[derive(Debug, Clone, Copy)] @@ -173,6 +175,7 @@ impl Dashboard { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, @@ -365,6 +368,16 @@ impl Dashboard { }); (" Diff ", content) } + OutputMode::ConflictProtocol => { + let content = self + .selected_conflict_protocol + .clone() + .unwrap_or_else(|| { + "No conflicted worktree available for the selected session." + .to_string() + }); + (" Conflict Protocol ", content) + } } } else { (" Output ", "No sessions. Press 'n' to start one.".to_string()) @@ -462,7 +475,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 [m]erge merge ready [M] auto-merge [w] 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 conflict proto[c]ol [m]erge merge ready [M] auto-merge [w] 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() { @@ -514,6 +527,7 @@ impl Dashboard { " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", + " c Show conflict-resolution protocol for selected conflicted worktree", " 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", @@ -727,6 +741,34 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::ConflictProtocol => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + } + } + + pub fn toggle_conflict_protocol_mode(&mut self) { + match self.output_mode { + OutputMode::ConflictProtocol => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + if self.selected_conflict_protocol.is_some() { + self.output_mode = OutputMode::ConflictProtocol; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.set_operator_note("showing worktree conflict protocol".to_string()); + } else { + self.set_operator_note( + "no conflicted worktree for selected session".to_string(), + ); + } + } } } @@ -1473,10 +1515,8 @@ impl Dashboard { } fn sync_selected_diff(&mut self) { - let worktree = self - .sessions - .get(self.selected_session) - .and_then(|session| session.worktree.as_ref()); + let session = self.sessions.get(self.selected_session); + let worktree = session.and_then(|session| session.worktree.as_ref()); self.selected_diff_summary = worktree.and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); @@ -1487,9 +1527,20 @@ impl Dashboard { .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()); + self.selected_conflict_protocol = session + .zip(worktree) + .zip(self.selected_merge_readiness.as_ref()) + .and_then(|((session, worktree), merge_readiness)| { + build_conflict_protocol(&session.id, worktree, merge_readiness) + }); if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { self.output_mode = OutputMode::SessionOutput; } + if self.output_mode == OutputMode::ConflictProtocol + && self.selected_conflict_protocol.is_none() + { + self.output_mode = OutputMode::SessionOutput; + } } fn sync_selected_messages(&mut self) { @@ -2410,6 +2461,44 @@ fn format_session_id(id: &str) -> String { id.chars().take(8).collect() } +fn build_conflict_protocol( + session_id: &str, + worktree: &crate::session::WorktreeInfo, + merge_readiness: &worktree::MergeReadiness, +) -> Option { + if merge_readiness.status != worktree::MergeReadinessStatus::Conflicted { + return None; + } + + let mut lines = vec![ + format!("Conflict protocol for {}", format_session_id(session_id)), + format!("Worktree {}", worktree.path.display()), + format!("Branch {} (base {})", worktree.branch, worktree.base_branch), + merge_readiness.summary.clone(), + ]; + + if !merge_readiness.conflicts.is_empty() { + lines.push("Conflicts".to_string()); + for conflict in &merge_readiness.conflicts { + lines.push(format!("- {conflict}")); + } + } + + lines.push("Resolution steps".to_string()); + lines.push(format!( + "1. Inspect current patch: ecc worktree-status {session_id} --patch" + )); + lines.push(format!("2. Open worktree: cd {}", worktree.path.display())); + lines.push("3. Resolve conflicts and stage files: git add ".to_string()); + lines.push(format!("4. Commit the resolution on {}: git commit", worktree.branch)); + lines.push(format!( + "5. Re-check readiness: ecc worktree-status {session_id} --check" + )); + lines.push(format!("6. Merge when clear: ecc merge-worktree {session_id}")); + + Some(lines.join("\n")) +} + fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { match action { manager::AssignmentAction::Spawned => "spawned", @@ -2566,6 +2655,41 @@ mod tests { assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs")); } + #[test] + fn toggle_conflict_protocol_mode_switches_to_protocol_view() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + 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()], + }); + dashboard.selected_conflict_protocol = Some( + "Conflict protocol for focus-12\nResolution steps\n1. Inspect current patch: ecc worktree-status focus-12345678 --patch" + .to_string(), + ); + + dashboard.toggle_conflict_protocol_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::ConflictProtocol); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing worktree conflict protocol") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Conflict Protocol")); + assert!(rendered.contains("Resolution steps")); + } + #[test] fn selected_session_metrics_text_includes_team_capacity_summary() { let mut dashboard = test_dashboard( @@ -3762,6 +3886,7 @@ mod tests { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions,