From 689235af169fdfe8c890a46a6aae9cebcc2013d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:30:08 -0700 Subject: [PATCH] feat: add ecc2 worktree pruning command --- ecc2/src/main.rs | 70 +++++++++++++++++++++++ ecc2/src/session/manager.rs | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d1094609..df918c65 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -203,6 +203,12 @@ enum Commands { #[arg(long)] check: bool, }, + /// Prune worktrees for inactive sessions and report any active sessions still holding one + PruneWorktrees { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Stop a running session Stop { /// Session ID or alias @@ -684,6 +690,14 @@ async fn main() -> Result<()> { std::process::exit(worktree_status_reports_exit_code(&reports)); } } + Some(Commands::PruneWorktrees { json }) => { + let outcome = session::manager::prune_inactive_worktrees(&db).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_prune_worktrees_human(&outcome)); + } + } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); @@ -1051,6 +1065,36 @@ fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { .unwrap_or(0) } +fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome) -> String { + let mut lines = Vec::new(); + + if outcome.cleaned_session_ids.is_empty() { + lines.push("Pruned 0 inactive worktree(s)".to_string()); + } else { + lines.push(format!( + "Pruned {} inactive worktree(s)", + outcome.cleaned_session_ids.len() + )); + for session_id in &outcome.cleaned_session_ids { + lines.push(format!("- cleaned {}", short_session(session_id))); + } + } + + if outcome.active_with_worktree_ids.is_empty() { + lines.push("No active sessions are holding worktrees".to_string()); + } else { + lines.push(format!( + "Skipped {} active session(s) still holding worktrees", + outcome.active_with_worktree_ids.len() + )); + for session_id in &outcome.active_with_worktree_ids { + lines.push(format!("- active {}", short_session(session_id))); + } + } + + lines.join("\n") +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1455,6 +1499,19 @@ mod tests { } } + #[test] + fn cli_parses_prune_worktrees_json_flag() { + let cli = Cli::try_parse_from(["ecc", "prune-worktrees", "--json"]) + .expect("prune-worktrees --json should parse"); + + match cli.command { + Some(Commands::PruneWorktrees { json }) => { + assert!(json); + } + _ => panic!("expected prune-worktrees subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -1489,6 +1546,19 @@ mod tests { assert!(text.contains("--- Branch diff vs main ---")); } + #[test] + fn format_prune_worktrees_human_reports_cleaned_and_active_sessions() { + let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { + cleaned_session_ids: vec!["deadbeefcafefeed".to_string()], + active_with_worktree_ids: vec!["facefeed12345678".to_string()], + }); + + assert!(text.contains("Pruned 1 inactive worktree(s)")); + assert!(text.contains("- cleaned deadbeef")); + assert!(text.contains("Skipped 1 active session(s) still holding worktrees")); + assert!(text.contains("- active facefeed")); + } + #[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 f8e62131..4ec104a6 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -610,6 +610,40 @@ pub async fn cleanup_session_worktree(db: &StateStore, id: &str) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreePruneOutcome { + pub cleaned_session_ids: Vec, + pub active_with_worktree_ids: Vec, +} + +pub async fn prune_inactive_worktrees(db: &StateStore) -> Result { + let sessions = db.list_sessions()?; + let mut cleaned_session_ids = Vec::new(); + let mut active_with_worktree_ids = Vec::new(); + + for session in sessions { + let Some(_) = session.worktree.as_ref() else { + continue; + }; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { + active_with_worktree_ids.push(session.id); + continue; + } + + cleanup_session_worktree(db, &session.id).await?; + cleaned_session_ids.push(session.id); + } + + Ok(WorktreePruneOutcome { + cleaned_session_ids, + active_with_worktree_ids, + }) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -1745,6 +1779,83 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn prune_inactive_worktrees_cleans_stopped_sessions_only() -> Result<()> { + let tempdir = TestDir::new("manager-prune-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 (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let active_id = create_session_in_dir( + &db, + &cfg, + "active worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + let stopped_id = create_session_in_dir( + &db, + &cfg, + "stopped worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &stopped_id, false).await?; + + let active_before = db + .get_session(&active_id)? + .context("active session should exist")?; + let active_path = active_before + .worktree + .clone() + .context("active session worktree missing")? + .path; + + let stopped_before = db + .get_session(&stopped_id)? + .context("stopped session should exist")?; + let stopped_path = stopped_before + .worktree + .clone() + .context("stopped session worktree missing")? + .path; + + let outcome = prune_inactive_worktrees(&db).await?; + + assert_eq!(outcome.cleaned_session_ids, vec![stopped_id.clone()]); + assert_eq!(outcome.active_with_worktree_ids, vec![active_id.clone()]); + assert!(active_path.exists(), "active worktree should remain"); + assert!(!stopped_path.exists(), "stopped worktree should be removed"); + + let active_after = db + .get_session(&active_id)? + .context("active session should still exist")?; + assert!( + active_after.worktree.is_some(), + "active session should keep worktree metadata" + ); + + let stopped_after = db + .get_session(&stopped_id)? + .context("stopped session should still exist")?; + assert!( + stopped_after.worktree.is_none(), + "stopped session worktree metadata should be cleared" + ); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?;