huskies: merge 841
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user