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, detect_base_branch, remove_worktree_sync};
|
|
|
|
|
use super::{WorktreeInfo, worktree_path};
|
|
|
|
|
|
|
|
|
|
/// Remove a git worktree and its branch.
|
|
|
|
|
pub async fn remove_worktree(
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
info: &WorktreeInfo,
|
|
|
|
|
config: &ProjectConfig,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
run_teardown_commands(&info.path, config).await?;
|
|
|
|
|
|
|
|
|
|
let root = project_root.to_path_buf();
|
|
|
|
|
let wt_path = info.path.clone();
|
|
|
|
|
let branch = info.branch.clone();
|
|
|
|
|
|
|
|
|
|
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("spawn_blocking: {e}"))?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 = config
|
|
|
|
|
.base_branch
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| detect_base_branch(project_root));
|
|
|
|
|
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) {
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
}
|