Files
huskies/server/src/worktree/sweep.rs
T

480 lines
15 KiB
Rust

//! Worktree orphan sweep helpers.
//!
//! `worktree_should_be_swept` and `sweep_with_lookup` are test helpers used
//! to verify that the reactive worktree-cleanup subscriber (`spawn_worktree_cleanup_subscriber`)
//! correctly identifies which stages warrant worktree removal. Production
//! worktree cleanup is driven by `TransitionFired` events in
//! `agents::pool::worktree_lifecycle`, not by periodic scanning.
#[cfg(test)]
use super::{list_worktrees, remove_worktree_by_story_id};
#[cfg(test)]
use crate::config::ProjectConfig;
use crate::pipeline_state::Stage;
#[cfg(test)]
use std::path::Path;
/// Returns `true` if a worktree for the given pipeline stage should be removed.
///
/// A worktree is swept when its story is `Done`, `Archived`, or not present in
/// the CRDT at all (i.e. `stage` is `None`). Active stages (`Backlog`,
/// `Coding`, `Qa`, `Merge`) are left alone.
pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool {
match stage {
None => true,
Some(
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. },
) => true,
Some(_) => false,
}
}
/// Internal sweep implementation for tests: walks worktrees and removes those
/// whose stories are in a terminal stage according to the provided `lookup`.
///
/// Accepts a `lookup` closure `fn(&str) -> Option<Stage>` that returns the
/// stage for a given story ID, or `None` if the story is not in the CRDT.
/// This indirection makes the sweep testable without a real CRDT.
#[cfg(test)]
pub(crate) async fn sweep_with_lookup<F>(
project_root: &Path,
config: &ProjectConfig,
lookup: F,
) -> usize
where
F: Fn(&str) -> Option<Stage>,
{
let entries = match list_worktrees(project_root) {
Ok(e) => e,
Err(err) => {
crate::slog_error!("[worktree-sweep] Failed to list worktrees: {err}");
return 0;
}
};
let mut removed = 0usize;
for entry in entries {
let stage = lookup(&entry.story_id);
if !worktree_should_be_swept(stage.as_ref()) {
continue;
}
crate::slog!(
"[worktree-sweep] Removing orphaned worktree for '{}' (stage: {})",
entry.story_id,
stage
.as_ref()
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "not in CRDT".to_string())
);
match remove_worktree_by_story_id(project_root, &entry.story_id, config).await {
Ok(()) => removed += 1,
Err(err) => {
crate::slog_error!(
"[worktree-sweep] Failed to remove worktree for '{}': {err}",
entry.story_id
);
}
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WatcherConfig;
use chrono::Utc;
use std::fs;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn init_git_repo(dir: &Path) {
use crate::git_test_support::git_ok;
git_ok(
Command::new("git").args(["init"]).current_dir(dir).output(),
"git init",
);
git_ok(
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output(),
"git config user.email",
);
git_ok(
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output(),
"git config user.name",
);
git_ok(
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output(),
"git commit",
);
}
fn empty_config() -> ProjectConfig {
ProjectConfig {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
disk_watch: Default::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,
status_push_enabled: true,
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
}
}
fn done_stage() -> Stage {
Stage::Done {
merged_at: Utc::now(),
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
}
}
fn archived_stage() -> Stage {
Stage::Archived {
archived_at: Utc::now(),
reason: crate::pipeline_state::ArchiveReason::Completed,
}
}
// ── worktree_should_be_swept unit tests ─────────────────────────────────
#[test]
fn should_sweep_when_not_in_crdt() {
assert!(worktree_should_be_swept(None));
}
#[test]
fn should_sweep_done() {
assert!(worktree_should_be_swept(Some(&done_stage())));
}
#[test]
fn should_sweep_archived() {
assert!(worktree_should_be_swept(Some(&archived_stage())));
}
#[test]
fn should_not_sweep_backlog() {
assert!(!worktree_should_be_swept(Some(&Stage::Backlog)));
}
#[test]
fn should_not_sweep_coding() {
assert!(!worktree_should_be_swept(Some(&Stage::Coding {
claim: None,
plan: Default::default(),
retries: 0,
})));
}
#[test]
fn should_not_sweep_qa() {
assert!(!worktree_should_be_swept(Some(&Stage::Qa)));
}
#[test]
fn should_not_sweep_merge() {
let stage = Stage::Merge {
feature_branch: crate::pipeline_state::BranchName("feature/x".to_string()),
commits_ahead: NonZeroU32::new(1).unwrap(),
claim: None,
retries: 0,
server_start_time: None,
};
assert!(!worktree_should_be_swept(Some(&stage)));
}
// ── Integration tests: sweep_with_lookup ────────────────────────────────
fn setup_project_with_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);
// Create a bare worktree directory (simulates a worktree without full git checkout)
let worktrees_dir = project_root.join(".huskies").join("worktrees");
fs::create_dir_all(worktrees_dir.join(story_id)).unwrap();
(tmp, project_root)
}
#[tokio::test]
async fn sweep_removes_done_worktree() {
let story_id = "100_done_story";
let (_tmp, project_root) = setup_project_with_worktree(story_id);
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
assert!(wt_dir.exists(), "worktree should exist before sweep");
// We can't remove via git worktree because it's not a real git worktree,
// so mock the removal: create a worktree directory and confirm sweep detects it.
// Instead, test that the sweep logic identifies the worktree as removable.
let config = empty_config();
let removed = sweep_with_lookup(&project_root, &config, |id| {
if id == story_id {
Some(done_stage())
} else {
None
}
})
.await;
// The worktree dir exists but is not a real git worktree, so remove_worktree_by_story_id
// will fail (not a git worktree). We can't assert removed == 1, but we can verify
// it was attempted (removed == 0 due to error is fine; what matters is no panic and
// the sweep continued).
// For a stronger test, use a real git worktree below.
let _ = removed; // sweep ran without panic
}
#[tokio::test]
async fn sweep_removes_real_done_worktree() {
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 story_id = "101_done_real";
let config = empty_config();
// Create a real git worktree.
super::super::create::create_worktree(&project_root, story_id, &config, 3001)
.await
.unwrap();
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
assert!(wt_dir.exists(), "worktree must exist before sweep");
let removed = sweep_with_lookup(&project_root, &config, |id| {
if id == story_id {
Some(done_stage())
} else {
None
}
})
.await;
assert_eq!(removed, 1, "sweep should remove the done worktree");
assert!(
!wt_dir.exists(),
"worktree directory should be gone after sweep"
);
}
#[tokio::test]
async fn sweep_does_not_remove_current_worktree() {
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 story_id = "102_current_story";
let config = empty_config();
super::super::create::create_worktree(&project_root, story_id, &config, 3001)
.await
.unwrap();
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
assert!(wt_dir.exists());
let removed = sweep_with_lookup(&project_root, &config, |id| {
if id == story_id {
Some(Stage::Coding {
claim: None,
plan: Default::default(),
retries: 0,
})
} else {
None
}
})
.await;
assert_eq!(removed, 0, "sweep must not remove current/coding worktrees");
assert!(wt_dir.exists(), "worktree directory must still exist");
}
#[tokio::test]
async fn sweep_does_not_remove_qa_worktree() {
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 story_id = "103_qa_story";
let config = empty_config();
super::super::create::create_worktree(&project_root, story_id, &config, 3001)
.await
.unwrap();
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
let removed = sweep_with_lookup(&project_root, &config, |id| {
if id == story_id {
Some(Stage::Qa)
} else {
None
}
})
.await;
assert_eq!(removed, 0, "sweep must not remove qa worktrees");
assert!(wt_dir.exists());
}
#[tokio::test]
async fn sweep_does_not_remove_merge_worktree() {
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 story_id = "104_merge_story";
let config = empty_config();
super::super::create::create_worktree(&project_root, story_id, &config, 3001)
.await
.unwrap();
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
let removed = sweep_with_lookup(&project_root, &config, |id| {
if id == story_id {
Some(Stage::Merge {
feature_branch: crate::pipeline_state::BranchName(
"feature/story-104_merge_story".to_string(),
),
commits_ahead: NonZeroU32::new(1).unwrap(),
claim: None,
retries: 0,
server_start_time: None,
})
} else {
None
}
})
.await;
assert_eq!(removed, 0, "sweep must not remove merge worktrees");
assert!(wt_dir.exists());
}
#[tokio::test]
async fn sweep_removes_worktree_not_in_crdt() {
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 story_id = "105_absent_story";
let config = empty_config();
super::super::create::create_worktree(&project_root, story_id, &config, 3001)
.await
.unwrap();
let wt_dir = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
assert!(wt_dir.exists());
// lookup returns None → story not in CRDT
let removed = sweep_with_lookup(&project_root, &config, |_| None).await;
assert_eq!(
removed, 1,
"sweep should remove worktree with no CRDT entry"
);
assert!(!wt_dir.exists());
}
#[tokio::test]
async fn sweep_continues_on_individual_failure() {
// One worktree that's a bare directory (not a real git worktree — removal fails)
// and one real worktree. The sweep should skip the failed one and remove the real one.
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 bad_id = "106_bad_wt";
let good_id = "107_good_wt";
let config = empty_config();
// Bad: bare directory, not a real git worktree.
fs::create_dir_all(project_root.join(".huskies").join("worktrees").join(bad_id)).unwrap();
// Good: real worktree.
super::super::create::create_worktree(&project_root, good_id, &config, 3001)
.await
.unwrap();
let removed = sweep_with_lookup(&project_root, &config, |_| None).await;
// The good one was removed; bad one failed but sweep continued.
assert!(
removed >= 1,
"at least the real worktree should be removed; got {removed}"
);
let good_wt = project_root
.join(".huskies")
.join("worktrees")
.join(good_id);
assert!(!good_wt.exists(), "real worktree should be gone");
}
}