From 2146619845080934d1f6b5be8f547cfd86cfbede Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 7 Apr 2026 11:44:40 -0700 Subject: [PATCH] feat: show ecc2 selected worktree diff summaries --- ecc2/src/tui/dashboard.rs | 21 +++++++ ecc2/src/worktree/mod.rs | 116 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 0bc6c5cd..451f860c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -16,6 +16,7 @@ use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, Output use crate::session::store::StateStore; use crate::session::manager; use crate::session::{Session, SessionMetrics, SessionState, WorktreeInfo}; +use crate::worktree; const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; @@ -33,6 +34,7 @@ pub struct Dashboard { sessions: Vec, session_output_cache: HashMap>, logs: Vec, + selected_diff_summary: Option, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -104,6 +106,7 @@ impl Dashboard { sessions, session_output_cache: HashMap::new(), logs: Vec::new(), + selected_diff_summary: None, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -114,6 +117,7 @@ impl Dashboard { session_table_state, }; dashboard.sync_selected_output(); + dashboard.sync_selected_diff(); dashboard.refresh_logs(); dashboard } @@ -449,6 +453,7 @@ impl Dashboard { self.sync_selection(); self.reset_output_view(); self.sync_selected_output(); + self.sync_selected_diff(); self.refresh_logs(); } Pane::Output => { @@ -480,6 +485,7 @@ impl Dashboard { self.sync_selection(); self.reset_output_view(); self.sync_selected_output(); + self.sync_selected_diff(); self.refresh_logs(); } Pane::Output => { @@ -578,6 +584,7 @@ impl Dashboard { self.sync_selection_by_id(selected_id.as_deref()); self.ensure_selected_pane_visible(); self.sync_selected_output(); + self.sync_selected_diff(); self.refresh_logs(); } @@ -624,6 +631,14 @@ impl Dashboard { } } + fn sync_selected_diff(&mut self) { + self.selected_diff_summary = self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.as_ref()) + .and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); + } + fn selected_session_id(&self) -> Option<&str> { self.sessions .get(self.selected_session) @@ -715,6 +730,9 @@ impl Dashboard { worktree.branch, worktree.base_branch )); lines.push(format!("Worktree {}", worktree.path.display())); + if let Some(diff_summary) = self.selected_diff_summary.as_ref() { + lines.push(format!("Diff {diff_summary}")); + } } lines.push(format!( @@ -1155,10 +1173,12 @@ mod tests { text: "last useful output".to_string(), }], ); + dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Branch ecc/focus | Base main")); assert!(text.contains("Worktree /tmp/ecc/focus")); + assert!(text.contains("Diff 1 file changed, 2 insertions(+)")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -1466,6 +1486,7 @@ mod tests { sessions, session_output_cache: HashMap::new(), logs: Vec::new(), + selected_diff_summary: None, selected_pane: Pane::Sessions, selected_session, show_help: false, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index d4183bdb..61896666 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -87,6 +87,56 @@ pub fn list() -> Result> { Ok(worktrees) } +pub fn diff_summary(worktree: &WorktreeInfo) -> Result> { + let base_ref = format!("{}...HEAD", worktree.base_branch); + let committed = git_diff_shortstat(&worktree.path, &[&base_ref])?; + let working = git_diff_shortstat(&worktree.path, &[])?; + + let mut parts = Vec::new(); + if let Some(committed) = committed { + parts.push(format!("Branch {committed}")); + } + if let Some(working) = working { + parts.push(format!("Working tree {working}")); + } + + if parts.is_empty() { + Ok(Some(format!("Clean relative to {}", worktree.base_branch))) + } else { + Ok(Some(parts.join(" | "))) + } +} + +fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .arg("--shortstat"); + command.args(extra_args); + + let output = command + .output() + .context("Failed to generate worktree diff summary")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree diff summary warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(None); + } + + let summary = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if summary.is_empty() { + Ok(None) + } else { + Ok(Some(summary)) + } +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -97,3 +147,69 @@ fn get_current_branch(repo_root: &Path) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use std::fs; + use std::process::Command; + use uuid::Uuid; + + fn run_git(repo: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git").arg("-C").arg(repo).args(args).output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(()) + } + + #[test] + fn diff_summary_reports_clean_and_dirty_worktrees() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-{}", 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", + ], + )?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + assert_eq!(diff_summary(&info)?, Some("Clean relative to main".to_string())); + + fs::write(worktree_dir.join("README.md"), "hello\nmore\n")?; + let dirty = diff_summary(&info)?.expect("dirty summary"); + assert!(dirty.contains("Working tree")); + assert!(dirty.contains("file changed")); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } +}