From 59b626d3ba48e29be436fe65699a3f5936d59980 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 13:38:34 +0000 Subject: [PATCH] huskies: merge 824 --- server/src/chat/commands/cleanup_worktrees.rs | 50 +++ server/src/chat/commands/mod.rs | 17 + .../matrix/bot/messages/on_room_message.rs | 29 ++ .../transport/matrix/cleanup_worktrees.rs | 151 +++++++ server/src/chat/transport/matrix/mod.rs | 2 + server/src/http/mcp/agent_tools/mod.rs | 3 +- server/src/http/mcp/agent_tools/worktree.rs | 16 + server/src/http/mcp/dispatch.rs | 1 + server/src/http/mcp/tools_list/agent_tools.rs | 13 + server/src/http/mcp/tools_list/mod.rs | 3 +- server/src/worktree/cleanup.rs | 371 ++++++++++++++++++ server/src/worktree/git.rs | 4 +- server/src/worktree/mod.rs | 2 + 13 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 server/src/chat/commands/cleanup_worktrees.rs create mode 100644 server/src/chat/transport/matrix/cleanup_worktrees.rs create mode 100644 server/src/worktree/cleanup.rs diff --git a/server/src/chat/commands/cleanup_worktrees.rs b/server/src/chat/commands/cleanup_worktrees.rs new file mode 100644 index 00000000..ce96916a --- /dev/null +++ b/server/src/chat/commands/cleanup_worktrees.rs @@ -0,0 +1,50 @@ +//! Cleanup-worktrees bot command — stub module required by the commands registry. +//! +//! The real async implementation lives in +//! `chat::transport::matrix::cleanup_worktrees`. This file exists so that +//! `commands/mod.rs` can declare the module, keeping the registry consistent +//! with the pattern used by other async commands (rmtree, start, rebuild, …). +//! +//! The fallback handler `handle_cleanup_worktrees_fallback` in `mod.rs` always +//! returns `None`; the matrix transport intercepts the command before +//! `try_handle_command` is reached. + +#[cfg(test)] +mod tests { + use crate::chat::commands::tests::{commands, try_cmd_addressed}; + + #[test] + fn cleanup_worktrees_is_registered() { + assert!( + commands().iter().any(|c| c.name == "cleanup_worktrees"), + "cleanup_worktrees must be in the command registry" + ); + } + + #[test] + fn cleanup_worktrees_has_description() { + let cmd = commands() + .iter() + .find(|c| c.name == "cleanup_worktrees") + .expect("cleanup_worktrees must be registered"); + assert!( + !cmd.description.is_empty(), + "cleanup_worktrees must have a description" + ); + } + + #[test] + fn cleanup_worktrees_fallback_returns_none() { + // The sync fallback returns None — the async handler in the Matrix + // transport handles the real work. + let result = try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy cleanup_worktrees", + ); + assert!( + result.is_none(), + "cleanup_worktrees fallback must return None (handled async): {result:?}" + ); + } +} diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index fca818ad..46306984 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -8,6 +8,7 @@ mod ambient; mod assign; mod backlog; +mod cleanup_worktrees; mod cost; mod coverage; mod depends; @@ -256,6 +257,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat", handler: setup::handle_setup, }, + BotCommand { + name: "cleanup_worktrees", + description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them", + handler: handle_cleanup_worktrees_fallback, + }, ] } @@ -402,6 +408,17 @@ fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option { None } +/// Fallback handler for the `cleanup_worktrees` command when it is not +/// intercepted by the async handler in `on_room_message`. In practice this is +/// never called — cleanup_worktrees is detected and handled before +/// `try_handle_command` is invoked. The entry exists in the registry only so +/// `help` lists it. +/// +/// Returns `None` to prevent the LLM from receiving "cleanup_worktrees" as a prompt. +fn handle_cleanup_worktrees_fallback(_ctx: &CommandContext) -> Option { + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index d4075406..9e8c1197 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -565,6 +565,35 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( } } + // Check for the cleanup_worktrees command, which requires async worktree + // removal and cannot be handled by the sync command registry. + if let Some(cleanup_cmd) = + super::super::super::cleanup_worktrees::extract_cleanup_worktrees_command( + &user_message, + &ctx.services.bot_name, + ctx.matrix_user_id.as_str(), + ) + { + let confirm = + cleanup_cmd == super::super::super::cleanup_worktrees::CleanupWorktreesCommand::Confirm; + slog!("[matrix-bot] Handling cleanup_worktrees command from {sender}: confirm={confirm}"); + let response = super::super::super::cleanup_worktrees::handle_cleanup_worktrees( + &effective_root, + confirm, + ) + .await; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // Check for the timer command, which requires async file I/O and cannot // be handled by the sync command registry. if let Some(timer_cmd) = crate::service::timer::extract_timer_command( diff --git a/server/src/chat/transport/matrix/cleanup_worktrees.rs b/server/src/chat/transport/matrix/cleanup_worktrees.rs new file mode 100644 index 00000000..37486337 --- /dev/null +++ b/server/src/chat/transport/matrix/cleanup_worktrees.rs @@ -0,0 +1,151 @@ +//! Cleanup-worktrees command: list or remove orphaned worktrees from chat. +//! +//! `{bot_name} cleanup_worktrees` lists orphaned worktrees (dry run). +//! `{bot_name} cleanup_worktrees --confirm` removes them. +//! +//! Both paths share the [`crate::worktree::run_cleanup`] handler. + +use crate::chat::util::strip_bot_mention; +use crate::config::ProjectConfig; +use std::path::Path; + +/// A parsed cleanup_worktrees command from a Matrix message body. +#[derive(Debug, PartialEq)] +pub enum CleanupWorktreesCommand { + /// List orphaned worktrees without removing (dry run). + DryRun, + /// Remove all orphaned worktrees. + Confirm, +} + +/// Parse a cleanup_worktrees command from a raw Matrix message body. +/// +/// Strips the bot mention prefix and checks whether the first word is +/// `cleanup_worktrees`. Returns `None` when the message is not this command. +pub fn extract_cleanup_worktrees_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let stripped = strip_bot_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + + let (cmd, args) = match trimmed.split_once(char::is_whitespace) { + Some((c, a)) => (c, a.trim()), + None => (trimmed, ""), + }; + + if !cmd.eq_ignore_ascii_case("cleanup_worktrees") { + return None; + } + + if args == "--confirm" { + Some(CleanupWorktreesCommand::Confirm) + } else { + Some(CleanupWorktreesCommand::DryRun) + } +} + +/// Handle a cleanup_worktrees command asynchronously. +/// +/// Delegates to [`crate::worktree::run_cleanup`] and formats the result as +/// a Markdown string suitable for posting to the chat room. +pub async fn handle_cleanup_worktrees(project_root: &Path, confirm: bool) -> String { + let config = match ProjectConfig::load(project_root) { + Ok(c) => c, + Err(e) => return format!("Failed to load project config: {e}"), + }; + + let report = crate::worktree::run_cleanup(project_root, &config, confirm).await; + crate::worktree::format_report(&report, confirm) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- extract_cleanup_worktrees_command ----------------------------------- + + #[test] + fn extract_dry_run_with_full_user_id() { + let cmd = extract_cleanup_worktrees_command( + "@timmy:home.local cleanup_worktrees", + "Timmy", + "@timmy:home.local", + ); + assert_eq!(cmd, Some(CleanupWorktreesCommand::DryRun)); + } + + #[test] + fn extract_confirm_with_display_name() { + let cmd = extract_cleanup_worktrees_command( + "Timmy cleanup_worktrees --confirm", + "Timmy", + "@timmy:home.local", + ); + assert_eq!(cmd, Some(CleanupWorktreesCommand::Confirm)); + } + + #[test] + fn extract_case_insensitive() { + let cmd = extract_cleanup_worktrees_command( + "Timmy CLEANUP_WORKTREES", + "Timmy", + "@timmy:home.local", + ); + assert_eq!(cmd, Some(CleanupWorktreesCommand::DryRun)); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_cleanup_worktrees_command( + "@timmy cleanup_worktrees --confirm", + "Timmy", + "@timmy:home.local", + ); + assert_eq!(cmd, Some(CleanupWorktreesCommand::Confirm)); + } + + #[test] + fn extract_other_command_returns_none() { + let cmd = + extract_cleanup_worktrees_command("Timmy rmtree 42", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + #[test] + fn extract_unrelated_text_returns_none() { + let cmd = extract_cleanup_worktrees_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + // -- handle_cleanup_worktrees integration -------------------------------- + + #[tokio::test] + async fn handle_dry_run_no_worktrees() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".huskies").join("worktrees")).unwrap(); + let response = handle_cleanup_worktrees(tmp.path(), false).await; + assert!( + response.contains("No orphaned"), + "unexpected response: {response}" + ); + } + + #[tokio::test] + async fn handle_confirm_no_worktrees() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".huskies").join("worktrees")).unwrap(); + let response = handle_cleanup_worktrees(tmp.path(), true).await; + assert!( + response.contains("No orphaned"), + "unexpected response: {response}" + ); + } +} diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index c0889f6e..919b134f 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -18,6 +18,8 @@ /// Auto-assign handler — listens for pipeline events and assigns stories to free agents. pub mod assign; mod bot; +/// Cleanup worktrees command — removes stale worktrees for completed or archived stories. +pub mod cleanup_worktrees; /// Matrix bot command handlers — parses and routes bot commands from Matrix messages. pub mod commands; pub(crate) mod config; diff --git a/server/src/http/mcp/agent_tools/mod.rs b/server/src/http/mcp/agent_tools/mod.rs index 8887d7b5..7b70f816 100644 --- a/server/src/http/mcp/agent_tools/mod.rs +++ b/server/src/http/mcp/agent_tools/mod.rs @@ -11,7 +11,8 @@ pub(crate) use lifecycle::{ tool_list_agents, tool_start_agent, tool_stop_agent, tool_wait_for_agent, }; pub(crate) use worktree::{ - tool_create_worktree, tool_get_editor_command, tool_list_worktrees, tool_remove_worktree, + tool_cleanup_worktrees, tool_create_worktree, tool_get_editor_command, tool_list_worktrees, + tool_remove_worktree, }; #[cfg(test)] diff --git a/server/src/http/mcp/agent_tools/worktree.rs b/server/src/http/mcp/agent_tools/worktree.rs index 74b5f3c6..dd4aa870 100644 --- a/server/src/http/mcp/agent_tools/worktree.rs +++ b/server/src/http/mcp/agent_tools/worktree.rs @@ -58,6 +58,22 @@ pub(crate) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Resu Ok(format!("Worktree for story '{story_id}' removed.")) } +/// MCP tool handler for `cleanup_worktrees` — removes stale worktrees whose stories are done or archived. +pub(crate) async fn tool_cleanup_worktrees( + args: &Value, + ctx: &AppContext, +) -> Result { + let confirm = args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let config = crate::config::ProjectConfig::load(&project_root)?; + let report = worktree::run_cleanup(&project_root, &config, confirm).await; + Ok(worktree::format_report(&report, confirm)) +} + pub(crate) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result { let worktree_path = args .get("worktree_path") diff --git a/server/src/http/mcp/dispatch.rs b/server/src/http/mcp/dispatch.rs index 3911f7ea..df63460c 100644 --- a/server/src/http/mcp/dispatch.rs +++ b/server/src/http/mcp/dispatch.rs @@ -43,6 +43,7 @@ pub(super) async fn handle_tools_call( "create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await, "list_worktrees" => agent_tools::tool_list_worktrees(ctx), "remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await, + "cleanup_worktrees" => agent_tools::tool_cleanup_worktrees(&args, ctx).await, // Editor tools "get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx), // Lifecycle tools diff --git a/server/src/http/mcp/tools_list/agent_tools.rs b/server/src/http/mcp/tools_list/agent_tools.rs index 6202805c..5e2b4191 100644 --- a/server/src/http/mcp/tools_list/agent_tools.rs +++ b/server/src/http/mcp/tools_list/agent_tools.rs @@ -167,6 +167,19 @@ pub(super) fn agent_tools() -> Vec { "required": ["story_id"] } }), + json!({ + "name": "cleanup_worktrees", + "description": "List orphaned worktrees (dry run) or remove them (confirm: true). A worktree is orphaned when its story is missing from the CRDT or is in Done/Archived stage.", + "inputSchema": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "description": "When true, remove all orphaned worktrees. When false (default), only list them." + } + } + } + }), json!({ "name": "get_editor_command", "description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.", diff --git a/server/src/http/mcp/tools_list/mod.rs b/server/src/http/mcp/tools_list/mod.rs index 1df65496..b5ac5509 100644 --- a/server/src/http/mcp/tools_list/mod.rs +++ b/server/src/http/mcp/tools_list/mod.rs @@ -88,7 +88,8 @@ mod tests { assert!(names.contains(&"remove_criterion")); assert!(names.contains(&"mesh_status")); assert!(names.contains(&"run_check")); - assert_eq!(tools.len(), 68); + assert!(names.contains(&"cleanup_worktrees")); + assert_eq!(tools.len(), 69); } #[test] diff --git a/server/src/worktree/cleanup.rs b/server/src/worktree/cleanup.rs new file mode 100644 index 00000000..6502fa1a --- /dev/null +++ b/server/src/worktree/cleanup.rs @@ -0,0 +1,371 @@ +//! Orphaned-worktree cleanup: dry-run discovery and confirmed removal. +//! +//! Both the chat-bot command and the MCP tool delegate to [`run_cleanup`], which +//! walks `.huskies/worktrees/`, compares each directory against the CRDT, and +//! optionally removes those whose story is missing or in `Done`/`Archived`. + +use crate::config::ProjectConfig; +use crate::pipeline_state::{Stage, read_typed}; +use crate::worktree::sweep::worktree_should_be_swept; +use crate::worktree::{WorktreeListEntry, list_worktrees, remove_worktree_by_story_id}; +use std::path::Path; + +/// Result of a cleanup pass — describes what was found and what was done. +pub struct CleanupReport { + /// Story IDs of all orphaned worktrees that were found. + pub orphaned: Vec, + /// Story IDs that were successfully removed (`confirm = true` only). + pub removed: Vec, + /// Story IDs that failed to remove, paired with the error message. + pub failed: Vec<(String, String)>, +} + +/// Run an orphaned-worktree cleanup using the live CRDT state. +/// +/// When `confirm` is `false`, worktrees are only discovered — nothing is +/// removed. When `confirm` is `true`, every orphaned worktree is removed via +/// [`remove_worktree_by_story_id`]. +/// +/// A worktree is considered orphaned when its story is absent from the CRDT, +/// or is in the `Done` or `Archived` stage. +pub async fn run_cleanup( + project_root: &Path, + config: &ProjectConfig, + confirm: bool, +) -> CleanupReport { + run_cleanup_with_lookup(project_root, config, confirm, |story_id| { + read_typed(story_id).ok().flatten().map(|item| item.stage) + }) + .await +} + +/// Internal implementation that accepts an injectable CRDT lookup for testing. +pub(crate) async fn run_cleanup_with_lookup( + project_root: &Path, + config: &ProjectConfig, + confirm: bool, + lookup: F, +) -> CleanupReport +where + F: Fn(&str) -> Option, +{ + let all_entries = match list_worktrees(project_root) { + Ok(e) => e, + Err(err) => { + crate::slog_error!("[worktree-cleanup] Failed to list worktrees: {err}"); + return CleanupReport { + orphaned: Vec::new(), + removed: Vec::new(), + failed: Vec::new(), + }; + } + }; + + let orphaned: Vec = all_entries + .into_iter() + .filter(|e| { + let stage = lookup(&e.story_id); + worktree_should_be_swept(stage.as_ref()) + }) + .collect(); + + let orphaned_ids: Vec = orphaned.iter().map(|e| e.story_id.clone()).collect(); + + if !confirm { + return CleanupReport { + orphaned: orphaned_ids, + removed: Vec::new(), + failed: Vec::new(), + }; + } + + let mut removed = Vec::new(); + let mut failed = Vec::new(); + + for entry in orphaned { + match remove_worktree_by_story_id(project_root, &entry.story_id, config).await { + Ok(()) => { + crate::slog!( + "[worktree-cleanup] Removed orphaned worktree '{}'", + entry.story_id + ); + removed.push(entry.story_id); + } + Err(err) => { + crate::slog_error!( + "[worktree-cleanup] Failed to remove worktree '{}': {err}", + entry.story_id + ); + failed.push((entry.story_id, err)); + } + } + } + + CleanupReport { + orphaned: orphaned_ids, + removed, + failed, + } +} + +/// Format a [`CleanupReport`] as a Markdown string suitable for chat or MCP output. +pub fn format_report(report: &CleanupReport, confirm: bool) -> String { + if report.orphaned.is_empty() { + return "No orphaned worktrees found.".to_string(); + } + + if !confirm { + let list = report + .orphaned + .iter() + .map(|id| format!("- `{id}`")) + .collect::>() + .join("\n"); + return format!( + "Found {} orphaned worktree(s):\n{list}\n\nRun with `--confirm` to remove them.", + report.orphaned.len() + ); + } + + let mut parts = Vec::new(); + + if !report.removed.is_empty() { + let list = report + .removed + .iter() + .map(|id| format!("- `{id}`")) + .collect::>() + .join("\n"); + parts.push(format!( + "Removed {} worktree(s):\n{list}", + report.removed.len() + )); + } + + if !report.failed.is_empty() { + let list = report + .failed + .iter() + .map(|(id, err)| format!("- `{id}`: {err}")) + .collect::>() + .join("\n"); + parts.push(format!( + "Failed to remove {} worktree(s):\n{list}", + report.failed.len() + )); + } + + if parts.is_empty() { + "Nothing to do.".to_string() + } else { + parts.join("\n\n") + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::WatcherConfig; + use chrono::Utc; + use std::fs; + use std::path::PathBuf; + use std::process::Command; + use tempfile::TempDir; + + fn init_git_repo(dir: &std::path::Path) { + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .expect("git init"); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .expect("git commit"); + } + + fn empty_config() -> ProjectConfig { + ProjectConfig { + component: vec![], + agent: vec![], + watcher: WatcherConfig::default(), + default_qa: "server".to_string(), + default_coder_model: None, + max_coders: None, + max_retries: 2, + base_branch: None, + rate_limit_notifications: true, + web_ui_status_consumer: true, + matrix_status_consumer: true, + slack_status_consumer: true, + discord_status_consumer: true, + whatsapp_status_consumer: true, + timezone: None, + rendezvous: None, + trusted_keys: Vec::new(), + crdt_require_token: false, + crdt_tokens: Vec::new(), + max_mesh_peers: 3, + gateway_url: None, + gateway_project: None, + } + } + + fn done_stage() -> Stage { + Stage::Done { + merged_at: Utc::now(), + merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), + } + } + + async fn setup_project_with_real_worktree(story_id: &str) -> (TempDir, PathBuf) { + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + init_git_repo(&project_root); + + let config = empty_config(); + super::super::create::create_worktree(&project_root, story_id, &config, 3001) + .await + .unwrap(); + + (tmp, project_root) + } + + // -- dry-run (confirm = false) ------------------------------------------- + + #[tokio::test] + async fn dry_run_lists_orphaned_without_removing() { + let story_id = "200_done_story"; + let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; + let wt_dir = project_root + .join(".huskies") + .join("worktrees") + .join(story_id); + assert!(wt_dir.exists()); + + let config = empty_config(); + let report = run_cleanup_with_lookup(&project_root, &config, false, |id| { + if id == story_id { + Some(done_stage()) + } else { + None + } + }) + .await; + + assert!( + report.orphaned.contains(&story_id.to_string()), + "orphaned list should include the done story" + ); + assert!( + report.removed.is_empty(), + "dry run should not remove anything" + ); + assert!(report.failed.is_empty()); + assert!(wt_dir.exists(), "worktree must still exist after dry run"); + } + + // -- confirmed removal --------------------------------------------------- + + #[tokio::test] + async fn confirm_removes_orphaned_worktree() { + let story_id = "201_purged_story"; + let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; + let wt_dir = project_root + .join(".huskies") + .join("worktrees") + .join(story_id); + assert!(wt_dir.exists()); + + let config = empty_config(); + // lookup returns None → story not in CRDT (purged) + let report = run_cleanup_with_lookup(&project_root, &config, true, |_| None).await; + + assert!( + report.orphaned.contains(&story_id.to_string()), + "purged story should be in orphaned list" + ); + assert!( + report.removed.contains(&story_id.to_string()), + "purged story worktree should be removed" + ); + assert!(report.failed.is_empty()); + assert!(!wt_dir.exists(), "worktree directory should be gone"); + } + + // -- running story preserved --------------------------------------------- + + #[tokio::test] + async fn confirm_preserves_running_story_worktree() { + let story_id = "202_running_story"; + let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await; + let wt_dir = project_root + .join(".huskies") + .join("worktrees") + .join(story_id); + assert!(wt_dir.exists()); + + let config = empty_config(); + let report = run_cleanup_with_lookup(&project_root, &config, true, |id| { + if id == story_id { + Some(Stage::Coding) + } else { + None + } + }) + .await; + + assert!( + report.orphaned.is_empty(), + "coding story should not be orphaned" + ); + assert!( + report.removed.is_empty(), + "coding story worktree must not be removed" + ); + assert!(wt_dir.exists(), "worktree must still exist"); + } + + // -- format_report ------------------------------------------------------- + + #[test] + fn format_report_no_orphans() { + let report = CleanupReport { + orphaned: vec![], + removed: vec![], + failed: vec![], + }; + let out = format_report(&report, false); + assert!(out.contains("No orphaned")); + } + + #[test] + fn format_report_dry_run_lists_orphans() { + let report = CleanupReport { + orphaned: vec!["100_old_story".to_string()], + removed: vec![], + failed: vec![], + }; + let out = format_report(&report, false); + assert!(out.contains("100_old_story")); + assert!(out.contains("--confirm")); + } + + #[test] + fn format_report_confirm_lists_removed() { + let report = CleanupReport { + orphaned: vec!["101_removed".to_string()], + removed: vec!["101_removed".to_string()], + failed: vec![], + }; + let out = format_report(&report, true); + assert!(out.contains("Removed")); + assert!(out.contains("101_removed")); + } +} diff --git a/server/src/worktree/git.rs b/server/src/worktree/git.rs index 9db981bd..3d794353 100644 --- a/server/src/worktree/git.rs +++ b/server/src/worktree/git.rs @@ -115,9 +115,9 @@ pub(crate) fn remove_worktree_sync( } } - // Delete branch (best effort) + // Delete branch (best effort, force-delete to handle unmerged branches) let _ = Command::new("git") - .args(["branch", "-d", branch]) + .args(["branch", "-D", branch]) .current_dir(project_root) .output(); diff --git a/server/src/worktree/mod.rs b/server/src/worktree/mod.rs index 5e6bcd52..8805203b 100644 --- a/server/src/worktree/mod.rs +++ b/server/src/worktree/mod.rs @@ -1,11 +1,13 @@ //! Git worktree management — creates, lists, and removes worktrees for agent isolation. use std::path::{Path, PathBuf}; +mod cleanup; mod create; mod git; mod remove; mod sweep; +pub use cleanup::{format_report, run_cleanup}; pub use create::create_worktree; pub use git::{migrate_slug_paths, prune_worktree_sync}; pub use remove::remove_worktree_by_story_id;