372 lines
11 KiB
Rust
372 lines
11 KiB
Rust
|
|
//! 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"));
|
||
|
|
}
|
||
|
|
}
|