From c755c03f0e67aa392ead627240ed972941cb1bf5 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 24 Mar 2026 15:00:08 +0000 Subject: [PATCH] storkit: merge 381_story_bot_command_to_delete_a_worktree --- server/src/matrix/bot.rs | 33 ++++ server/src/matrix/commands/mod.rs | 15 ++ server/src/matrix/mod.rs | 1 + server/src/matrix/rmtree.rs | 282 ++++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 server/src/matrix/rmtree.rs diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 0222d1b..7ecd5c8 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -919,6 +919,39 @@ async fn on_room_message( return; } + // Check for the rmtree command, which requires async agent/worktree ops + // and cannot be handled by the sync command registry. + if let Some(rmtree_cmd) = super::rmtree::extract_rmtree_command( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) { + let response = match rmtree_cmd { + super::rmtree::RmtreeCommand::Rmtree { story_number } => { + slog!( + "[matrix-bot] Handling rmtree command from {sender}: story {story_number}" + ); + super::rmtree::handle_rmtree( + &ctx.bot_name, + &story_number, + &ctx.project_root, + &ctx.agents, + ) + .await + } + super::rmtree::RmtreeCommand::BadArgs => { + format!("Usage: `{} rmtree `", ctx.bot_name) + } + }; + 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 start command, which requires async agent ops and cannot // be handled by the sync command registry. if let Some(start_cmd) = super::start::extract_start_command( diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs index 8b582d2..8bbdd57 100644 --- a/server/src/matrix/commands/mod.rs +++ b/server/src/matrix/commands/mod.rs @@ -137,6 +137,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Remove a work item from the pipeline: `delete `", handler: handle_delete_fallback, }, + BotCommand { + name: "rmtree", + description: "Delete the worktree for a story without removing it from the pipeline: `rmtree `", + handler: handle_rmtree_fallback, + }, BotCommand { name: "reset", description: "Clear the current Claude Code session and start fresh", @@ -252,6 +257,16 @@ fn handle_start_fallback(_ctx: &CommandContext) -> Option { None } +/// Fallback handler for the `rmtree` command when it is not intercepted by +/// the async handler in `on_room_message`. In practice this is never called — +/// rmtree 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 "rmtree" as a prompt. +fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option { + None +} + /// Fallback handler for the `delete` command when it is not intercepted by /// the async handler in `on_room_message`. In practice this is never called — /// delete is detected and handled before `try_handle_command` is invoked. diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 377c4ea..11427a8 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -22,6 +22,7 @@ pub mod delete; pub mod htop; pub mod rebuild; pub mod reset; +pub mod rmtree; pub mod start; pub mod notifications; pub mod transport_impl; diff --git a/server/src/matrix/rmtree.rs b/server/src/matrix/rmtree.rs new file mode 100644 index 0000000..8baf632 --- /dev/null +++ b/server/src/matrix/rmtree.rs @@ -0,0 +1,282 @@ +//! Rmtree command: delete the worktree for a story without deleting the story file. +//! +//! `{bot_name} rmtree {number}` finds the worktree for the given story number, +//! stops any running agent, and removes the worktree directory and branch. +//! The story file in the pipeline is left untouched. + +use crate::agents::{AgentPool, AgentStatus}; +use std::path::Path; + +/// A parsed rmtree command from a Matrix message body. +#[derive(Debug, PartialEq)] +pub enum RmtreeCommand { + /// Remove the worktree for the story with this number. + Rmtree { story_number: String }, + /// The user typed `rmtree` but without a valid numeric argument. + BadArgs, +} + +/// Parse an rmtree command from a raw Matrix message body. +/// +/// Strips the bot mention prefix and checks whether the first word is `rmtree`. +/// Returns `None` when the message is not an rmtree command at all. +pub fn extract_rmtree_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let stripped = strip_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("rmtree") { + return None; + } + + if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) { + Some(RmtreeCommand::Rmtree { + story_number: args.to_string(), + }) + } else { + Some(RmtreeCommand::BadArgs) + } +} + +/// Handle an rmtree command asynchronously. +/// +/// Finds the worktree for `story_number` under `.storkit/worktrees/`, stops any +/// running agent, and removes the worktree directory and its feature branch. +/// Returns a markdown-formatted response string. +pub async fn handle_rmtree( + bot_name: &str, + story_number: &str, + project_root: &Path, + agents: &AgentPool, +) -> String { + // Find the story_id by listing worktree directories. + let worktrees = match crate::worktree::list_worktrees(project_root) { + Ok(wt) => wt, + Err(e) => return format!("Failed to list worktrees: {e}"), + }; + + let entry = worktrees.into_iter().find(|e| { + e.story_id + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .map(|n| n == story_number) + .unwrap_or(false) + }); + + let story_id = match entry { + Some(e) => e.story_id, + None => { + return format!("No worktree found for story **{story_number}**."); + } + }; + + // Stop any running or pending agents for this story. + let running_agents: Vec<(String, String)> = agents + .list_agents() + .unwrap_or_default() + .into_iter() + .filter(|a| { + a.story_id == story_id + && matches!(a.status, AgentStatus::Running | AgentStatus::Pending) + }) + .map(|a| (a.story_id.clone(), a.agent_name.clone())) + .collect(); + + let mut stopped_agents: Vec = Vec::new(); + for (sid, agent_name) in &running_agents { + if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await { + return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}"); + } + stopped_agents.push(agent_name.clone()); + } + + // Remove the worktree. + if let Err(e) = crate::worktree::prune_worktree_sync(project_root, &story_id) { + return format!("Failed to remove worktree for story {story_number}: {e}"); + } + + crate::slog!( + "[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})" + ); + + let mut response = format!("Removed worktree for **{story_id}**."); + if !stopped_agents.is_empty() { + let agent_list = stopped_agents.join(", "); + response.push_str(&format!(" Stopped agent(s): {agent_list}.")); + } + response +} + +/// Strip the bot mention prefix from a raw Matrix message body. +fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { + let trimmed = message.trim(); + if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { + return rest; + } + if let Some(localpart) = bot_user_id.split(':').next() + && let Some(rest) = strip_prefix_ci(trimmed, localpart) + { + return rest; + } + if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { + return rest; + } + trimmed +} + +fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + if text.len() < prefix.len() { + return None; + } + if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + let rest = &text[prefix.len()..]; + match rest.chars().next() { + None => Some(rest), + Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, + _ => Some(rest), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- extract_rmtree_command --------------------------------------------- + + #[test] + fn extract_with_full_user_id() { + let cmd = extract_rmtree_command( + "@timmy:home.local rmtree 42", + "Timmy", + "@timmy:home.local", + ); + assert_eq!( + cmd, + Some(RmtreeCommand::Rmtree { + story_number: "42".to_string() + }) + ); + } + + #[test] + fn extract_with_display_name() { + let cmd = extract_rmtree_command("Timmy rmtree 310", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(RmtreeCommand::Rmtree { + story_number: "310".to_string() + }) + ); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_rmtree_command("@timmy rmtree 7", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(RmtreeCommand::Rmtree { + story_number: "7".to_string() + }) + ); + } + + #[test] + fn extract_case_insensitive_command() { + let cmd = extract_rmtree_command("Timmy RMTREE 99", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(RmtreeCommand::Rmtree { + story_number: "99".to_string() + }) + ); + } + + #[test] + fn extract_no_args_is_bad_args() { + let cmd = extract_rmtree_command("Timmy rmtree", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(RmtreeCommand::BadArgs)); + } + + #[test] + fn extract_non_numeric_arg_is_bad_args() { + let cmd = extract_rmtree_command("Timmy rmtree foo", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(RmtreeCommand::BadArgs)); + } + + #[test] + fn extract_non_rmtree_command_returns_none() { + let cmd = extract_rmtree_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + // -- handle_rmtree (integration-style, uses temp filesystem) ----------- + + #[tokio::test] + async fn handle_rmtree_returns_not_found_for_unknown_number() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + std::fs::create_dir_all(project_root.join(".storkit").join("worktrees")).unwrap(); + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); + let response = handle_rmtree("Timmy", "999", project_root, &agents).await; + assert!( + response.contains("No worktree found") && response.contains("999"), + "unexpected response: {response}" + ); + } + + #[tokio::test] + async fn handle_rmtree_removes_worktree_and_confirms() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path().join("my-project"); + std::fs::create_dir_all(&project_root).unwrap(); + + // Init a git repo so worktree ops work. + std::process::Command::new("git") + .args(["init"]) + .current_dir(&project_root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&project_root) + .output() + .unwrap(); + + // Create a real git worktree for story 42. + let story_id = "42_story_some_feature"; + let wt_path = crate::worktree::worktree_path(&project_root, story_id); + let branch = format!("feature/story-{story_id}"); + std::process::Command::new("git") + .args(["worktree", "add", &wt_path.to_string_lossy(), "-b", &branch]) + .current_dir(&project_root) + .output() + .unwrap(); + assert!(wt_path.exists(), "worktree should exist before rmtree"); + + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); + let response = handle_rmtree("Timmy", "42", &project_root, &agents).await; + + assert!( + response.contains("42_story_some_feature"), + "unexpected response: {response}" + ); + assert!(!wt_path.exists(), "worktree directory should be removed"); + } +}