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

279 lines
9.0 KiB
Rust
Raw Normal View History

2026-04-28 14:01:24 +00:00
//! Async worktree removal operations.
use crate::config::ProjectConfig;
use std::path::Path;
use super::create::run_teardown_commands;
use super::git::{branch_name, remove_worktree_sync, resolve_base_branch};
2026-04-28 14:01:24 +00:00
use super::{WorktreeInfo, worktree_path};
/// Remove a git worktree and its branch.
///
/// Story 1199: measures the worktree's `target/` size before removal (once
/// removed, there's nothing left to measure) and logs bytes reclaimed on
/// success, so terminal-story worktree cleanup reports its disk impact the
/// same way the on-demand `gc` pass does.
2026-04-28 14:01:24 +00:00
pub async fn remove_worktree(
project_root: &Path,
info: &WorktreeInfo,
config: &ProjectConfig,
) -> Result<(), String> {
run_teardown_commands(&info.path, config).await?;
let target_path = info.path.join("target");
let target_bytes = tokio::task::spawn_blocking(move || {
crate::service::disk_watch::io::dir_size_bytes(&target_path)
})
.await
.unwrap_or(0);
2026-04-28 14:01:24 +00:00
let root = project_root.to_path_buf();
let wt_path = info.path.clone();
let branch = info.branch.clone();
let result =
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?;
if result.is_ok() && target_bytes > 0 {
crate::slog!(
"[worktree-remove] Reclaimed {target_bytes} bytes from '{}' target/ on removal",
info.path.display()
);
}
result
2026-04-28 14:01:24 +00:00
}
/// Remove a git worktree by story ID, deriving the path and branch deterministically.
pub async fn remove_worktree_by_story_id(
project_root: &Path,
story_id: &str,
config: &ProjectConfig,
) -> Result<(), String> {
let path = worktree_path(project_root, story_id);
if !path.exists() {
return Err(format!("Worktree not found for story: {story_id}"));
}
let branch = branch_name(story_id);
let base_branch = resolve_base_branch(project_root, config.base_branch.as_deref());
2026-04-28 14:01:24 +00:00
let info = WorktreeInfo {
path,
branch,
base_branch,
};
remove_worktree(project_root, &info, config).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WatcherConfig;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_git_repo(dir: &std::path::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",
);
2026-04-28 14:01:24 +00:00
}
fn empty_config() -> ProjectConfig {
ProjectConfig {
component: vec![],
agent: vec![],
watcher: WatcherConfig::default(),
disk_watch: Default::default(),
2026-04-28 14:01:24 +00:00
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,
2026-04-29 21:28:41 +00:00
status_push_enabled: true,
2026-05-14 09:33:50 +00:00
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
2026-04-28 14:01:24 +00:00
}
}
#[tokio::test]
async fn remove_worktree_by_story_id_returns_err_when_not_found() {
let tmp = TempDir::new().unwrap();
let result =
remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &empty_config()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("Worktree not found for story: 99_nonexistent")
);
}
#[tokio::test]
async fn remove_worktree_by_story_id_removes_existing_worktree() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
super::super::create::create_worktree(
&project_root,
"88_remove_by_id",
&empty_config(),
3001,
)
.await
.unwrap();
let result =
remove_worktree_by_story_id(&project_root, "88_remove_by_id", &empty_config()).await;
assert!(
result.is_ok(),
"Expected removal to succeed: {:?}",
result.err()
);
}
2026-04-29 21:14:27 +00:00
#[tokio::test]
async fn remove_worktree_by_story_id_cleans_git_metadata_and_branch() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
let story_id = "89_regression_remove";
let expected_branch = format!("feature/story-{story_id}");
super::super::create::create_worktree(&project_root, story_id, &empty_config(), 3001)
.await
.unwrap();
let wt_path = super::super::worktree_path(&project_root, story_id);
assert!(wt_path.exists(), "worktree must exist before removal");
let result = remove_worktree_by_story_id(&project_root, story_id, &empty_config()).await;
assert!(
result.is_ok(),
"Expected removal to succeed: {:?}",
result.err()
);
// Regression: `git worktree list` must not show the removed worktree.
let wt_list_output = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&project_root)
.output()
.expect("git worktree list");
let wt_list = String::from_utf8_lossy(&wt_list_output.stdout);
assert!(
!wt_list.contains(&*wt_path.to_string_lossy()),
"git worktree list should not contain the removed worktree path, but got:\n{wt_list}"
);
// Regression: feature branch must be deleted after worktree removal.
let branch_output = std::process::Command::new("git")
.args(["branch", "--list", &expected_branch])
.current_dir(&project_root)
.output()
.expect("git branch --list");
let branch_list = String::from_utf8_lossy(&branch_output.stdout);
assert!(
branch_list.trim().is_empty(),
"Feature branch '{expected_branch}' should be deleted, but found: {branch_list}"
);
}
2026-04-28 14:01:24 +00:00
#[tokio::test]
async fn remove_worktree_async_removes_directory() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
let info = super::super::create::create_worktree(
&project_root,
"77_remove_async",
&empty_config(),
3001,
)
.await
.unwrap();
let path = info.path.clone();
assert!(path.exists());
remove_worktree(&project_root, &info, &empty_config())
.await
.unwrap();
assert!(!path.exists());
}
/// Story 1199 AC4: removal reclaims a populated `target/` dir along with
/// the rest of the worktree.
#[tokio::test]
async fn remove_worktree_reclaims_populated_target_dir() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
let info = super::super::create::create_worktree(
&project_root,
"90_reclaim_target",
&empty_config(),
3001,
)
.await
.unwrap();
let target_dir = info.path.join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 1024]).unwrap();
assert!(target_dir.exists());
remove_worktree(&project_root, &info, &empty_config())
.await
.unwrap();
assert!(!info.path.exists(), "whole worktree must be gone");
assert!(!target_dir.exists(), "target/ must be reclaimed");
}
2026-04-28 14:01:24 +00:00
}