//! 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"); } }