huskies: merge 824
This commit is contained in:
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
mod ambient;
|
mod ambient;
|
||||||
mod assign;
|
mod assign;
|
||||||
mod backlog;
|
mod backlog;
|
||||||
|
mod cleanup_worktrees;
|
||||||
mod cost;
|
mod cost;
|
||||||
mod coverage;
|
mod coverage;
|
||||||
mod depends;
|
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",
|
description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
||||||
handler: setup::handle_setup,
|
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<String> {
|
|||||||
None
|
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<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
// Check for the timer command, which requires async file I/O and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(timer_cmd) = crate::service::timer::extract_timer_command(
|
if let Some(timer_cmd) = crate::service::timer::extract_timer_command(
|
||||||
|
|||||||
@@ -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<CleanupWorktreesCommand> {
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
/// Auto-assign handler — listens for pipeline events and assigns stories to free agents.
|
/// Auto-assign handler — listens for pipeline events and assigns stories to free agents.
|
||||||
pub mod assign;
|
pub mod assign;
|
||||||
mod bot;
|
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.
|
/// Matrix bot command handlers — parses and routes bot commands from Matrix messages.
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub(crate) mod config;
|
pub(crate) mod config;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ pub(crate) use lifecycle::{
|
|||||||
tool_list_agents, tool_start_agent, tool_stop_agent, tool_wait_for_agent,
|
tool_list_agents, tool_start_agent, tool_stop_agent, tool_wait_for_agent,
|
||||||
};
|
};
|
||||||
pub(crate) use worktree::{
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ pub(crate) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Resu
|
|||||||
Ok(format!("Worktree for story '{story_id}' removed."))
|
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<String, String> {
|
||||||
|
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<String, String> {
|
pub(crate) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let worktree_path = args
|
let worktree_path = args
|
||||||
.get("worktree_path")
|
.get("worktree_path")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub(super) async fn handle_tools_call(
|
|||||||
"create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await,
|
"create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await,
|
||||||
"list_worktrees" => agent_tools::tool_list_worktrees(ctx),
|
"list_worktrees" => agent_tools::tool_list_worktrees(ctx),
|
||||||
"remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await,
|
"remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await,
|
||||||
|
"cleanup_worktrees" => agent_tools::tool_cleanup_worktrees(&args, ctx).await,
|
||||||
// Editor tools
|
// Editor tools
|
||||||
"get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx),
|
"get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx),
|
||||||
// Lifecycle tools
|
// Lifecycle tools
|
||||||
|
|||||||
@@ -167,6 +167,19 @@ pub(super) fn agent_tools() -> Vec<Value> {
|
|||||||
"required": ["story_id"]
|
"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!({
|
json!({
|
||||||
"name": "get_editor_command",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"remove_criterion"));
|
assert!(names.contains(&"remove_criterion"));
|
||||||
assert!(names.contains(&"mesh_status"));
|
assert!(names.contains(&"mesh_status"));
|
||||||
assert!(names.contains(&"run_check"));
|
assert!(names.contains(&"run_check"));
|
||||||
assert_eq!(tools.len(), 68);
|
assert!(names.contains(&"cleanup_worktrees"));
|
||||||
|
assert_eq!(tools.len(), 69);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
/// Story IDs that were successfully removed (`confirm = true` only).
|
||||||
|
pub removed: Vec<String>,
|
||||||
|
/// 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<F>(
|
||||||
|
project_root: &Path,
|
||||||
|
config: &ProjectConfig,
|
||||||
|
confirm: bool,
|
||||||
|
lookup: F,
|
||||||
|
) -> CleanupReport
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Option<Stage>,
|
||||||
|
{
|
||||||
|
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<WorktreeListEntry> = all_entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
let stage = lookup(&e.story_id);
|
||||||
|
worktree_should_be_swept(stage.as_ref())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let orphaned_ids: Vec<String> = 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
let _ = Command::new("git")
|
||||||
.args(["branch", "-d", branch])
|
.args(["branch", "-D", branch])
|
||||||
.current_dir(project_root)
|
.current_dir(project_root)
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! Git worktree management — creates, lists, and removes worktrees for agent isolation.
|
//! Git worktree management — creates, lists, and removes worktrees for agent isolation.
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
mod cleanup;
|
||||||
mod create;
|
mod create;
|
||||||
mod git;
|
mod git;
|
||||||
mod remove;
|
mod remove;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
|
|
||||||
|
pub use cleanup::{format_report, run_cleanup};
|
||||||
pub use create::create_worktree;
|
pub use create::create_worktree;
|
||||||
pub use git::{migrate_slug_paths, prune_worktree_sync};
|
pub use git::{migrate_slug_paths, prune_worktree_sync};
|
||||||
pub use remove::remove_worktree_by_story_id;
|
pub use remove::remove_worktree_by_story_id;
|
||||||
|
|||||||
Reference in New Issue
Block a user