storkit: merge 381_story_bot_command_to_delete_a_worktree
This commit is contained in:
@@ -919,6 +919,39 @@ async fn on_room_message(
|
|||||||
return;
|
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 <number>`", 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
|
// Check for the start command, which requires async agent ops and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(start_cmd) = super::start::extract_start_command(
|
if let Some(start_cmd) = super::start::extract_start_command(
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||||
handler: handle_delete_fallback,
|
handler: handle_delete_fallback,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "rmtree",
|
||||||
|
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
|
||||||
|
handler: handle_rmtree_fallback,
|
||||||
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "reset",
|
name: "reset",
|
||||||
description: "Clear the current Claude Code session and start fresh",
|
description: "Clear the current Claude Code session and start fresh",
|
||||||
@@ -252,6 +257,16 @@ fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
|
|||||||
None
|
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<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Fallback handler for the `delete` command when it is not intercepted by
|
/// 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 —
|
/// the async handler in `on_room_message`. In practice this is never called —
|
||||||
/// delete is detected and handled before `try_handle_command` is invoked.
|
/// delete is detected and handled before `try_handle_command` is invoked.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod delete;
|
|||||||
pub mod htop;
|
pub mod htop;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod reset;
|
pub mod reset;
|
||||||
|
pub mod rmtree;
|
||||||
pub mod start;
|
pub mod start;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod transport_impl;
|
pub mod transport_impl;
|
||||||
|
|||||||
282
server/src/matrix/rmtree.rs
Normal file
282
server/src/matrix/rmtree.rs
Normal file
@@ -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<RmtreeCommand> {
|
||||||
|
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<String> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user