447 lines
14 KiB
Rust
447 lines
14 KiB
Rust
|
|
//! Periodic orphan sweep — removes worktrees whose stories are done, archived,
|
||
|
|
//! or absent from the CRDT.
|
||
|
|
|
||
|
|
use crate::config::ProjectConfig;
|
||
|
|
use crate::pipeline_state::{Stage, read_typed};
|
||
|
|
use std::path::Path;
|
||
|
|
|
||
|
|
use super::{list_worktrees, remove_worktree_by_story_id};
|
||
|
|
|
||
|
|
/// Returns `true` if a worktree for the given pipeline stage should be removed
|
||
|
|
/// by the orphan sweep.
|
||
|
|
///
|
||
|
|
/// 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 { .. }) | Some(Stage::Archived { .. }) => true,
|
||
|
|
Some(_) => false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove orphaned worktrees whose stories are done, archived, or absent from
|
||
|
|
/// the CRDT.
|
||
|
|
///
|
||
|
|
/// Walks `.huskies/worktrees/`, checks each story's stage via `lookup`, and
|
||
|
|
/// calls [`remove_worktree_by_story_id`] for any that should be swept.
|
||
|
|
/// Failures are logged individually; the sweep continues regardless.
|
||
|
|
///
|
||
|
|
/// Returns the number of worktrees successfully removed.
|
||
|
|
pub async fn sweep_orphaned_worktrees(project_root: &Path, config: &ProjectConfig) -> usize {
|
||
|
|
sweep_with_lookup(project_root, config, |story_id| {
|
||
|
|
read_typed(story_id).ok().flatten().map(|item| item.stage)
|
||
|
|
})
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Internal sweep implementation that accepts a custom CRDT lookup function.
|
||
|
|
///
|
||
|
|
/// 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.
|
||
|
|
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) {
|
||
|
|
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()),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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)));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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(),
|
||
|
|
};
|
||
|
|
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)
|
||
|
|
} 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(),
|
||
|
|
})
|
||
|
|
} 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");
|
||
|
|
}
|
||
|
|
}
|