use console::style;
use std::path::Path;
use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
use crate::error::Result;
use crate::git;
use crate::messages;
use super::display::get_worktree_status;
use super::pr_cache::{PrCache, PrState};
pub(super) fn branch_is_merged(
branch_name: &str,
repo: &Path,
pr_cache: &PrCache,
) -> Option<String> {
let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
let base_branch = git::get_config(&base_key, Some(repo))
.unwrap_or_else(|| git::detect_default_branch(Some(repo)));
if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
return Some(base_branch);
}
if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
return Some(base_branch);
}
None
}
pub fn clean_worktrees(
no_cache: bool,
merged: bool,
older_than: Option<u64>,
interactive: bool,
dry_run: bool,
force: bool,
) -> Result<()> {
let repo = git::get_repo_root(None)?;
if !merged && older_than.is_none() && !interactive {
eprintln!(
"Error: Please specify at least one cleanup criterion:\n \
--merged, --older-than, or -i/--interactive"
);
return Ok(());
}
let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
let mut to_delete: Vec<(String, String, String)> = Vec::new();
for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
let mut should_delete = false;
let mut reasons = Vec::new();
if merged {
if let Some(base_branch) = branch_is_merged(&branch_name, &repo, &pr_cache) {
should_delete = true;
reasons.push(format!("merged into {}", base_branch));
}
}
if let Some(days) = older_than {
if let Some(age) = path_age_days(&path) {
let age_days = age as u64;
if age_days >= days {
should_delete = true;
reasons.push(format!("older than {} days ({} days)", days, age_days));
}
}
}
if should_delete {
to_delete.push((
branch_name.clone(),
path.to_string_lossy().to_string(),
reasons.join(", "),
));
}
}
if interactive && to_delete.is_empty() {
println!("{}\n", style("Available worktrees:").cyan().bold());
let mut all_wt = Vec::new();
for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
let status = get_worktree_status(&path, &repo, Some(branch_name.as_str()), &pr_cache);
println!(" [{:8}] {:<30} {}", status, branch_name, path.display());
all_wt.push((branch_name, path.to_string_lossy().to_string()));
}
println!();
println!("Enter branch names to delete (space-separated), or 'all' for all:");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.eq_ignore_ascii_case("all") {
to_delete = all_wt
.into_iter()
.map(|(b, p)| (b, p, "user selected".to_string()))
.collect();
} else {
let selected: Vec<&str> = input.split_whitespace().collect();
to_delete = all_wt
.into_iter()
.filter(|(b, _)| selected.contains(&b.as_str()))
.map(|(b, p)| (b, p, "user selected".to_string()))
.collect();
}
if to_delete.is_empty() {
println!("{}", style("No worktrees selected for deletion").yellow());
return Ok(());
}
}
let mut busy_skipped: Vec<(
String,
Vec<crate::operations::busy::BusyInfo>,
Vec<crate::operations::busy::BusyInfo>,
)> = Vec::new();
if !force {
let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
for (branch, path, reason) in to_delete.into_iter() {
let (hard, soft) =
crate::operations::busy::detect_busy_tiered(std::path::Path::new(&path));
if hard.is_empty() && soft.is_empty() {
kept.push((branch, path, reason));
} else {
busy_skipped.push((branch, hard, soft));
}
}
to_delete = kept;
}
if !busy_skipped.is_empty() {
println!(
"{}",
style(format!(
"Skipping {} busy worktree(s) (use --force to override):",
busy_skipped.len()
))
.yellow()
);
for (branch, hard, soft) in &busy_skipped {
eprint!(
"{}",
crate::operations::busy_messages::render_refusal(branch, hard, soft)
);
}
println!();
}
if to_delete.is_empty() {
println!(
"{} No worktrees match the cleanup criteria\n",
style("*").green().bold()
);
return Ok(());
}
let prefix = if dry_run { "DRY RUN: " } else { "" };
println!(
"\n{}\n",
style(format!("{}Worktrees to delete:", prefix))
.yellow()
.bold()
);
for (branch, path, reason) in &to_delete {
println!(" - {:<30} ({})", branch, reason);
println!(" Path: {}", path);
}
println!();
if dry_run {
println!(
"{} Would delete {} worktree(s)",
style("*").cyan().bold(),
to_delete.len()
);
println!("Run without --dry-run to actually delete them");
return Ok(());
}
let mut deleted = 0u32;
for (branch, _, _) in &to_delete {
println!("{}", style(format!("Deleting {}...", branch)).yellow());
match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
Ok(()) => {
println!("{} Deleted {}", style("*").green().bold(), branch);
deleted += 1;
}
Err(e) => {
println!(
"{} Failed to delete {}: {}",
style("x").red().bold(),
branch,
e
);
}
}
}
println!(
"\n{}\n",
style(messages::cleanup_complete(deleted)).green().bold()
);
println!("{}", style("Pruning stale worktree metadata...").dim());
let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
println!("{}\n", style("* Prune complete").dim());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::test_env::{env_lock, EnvGuard};
fn init_git_repo(path: &std::path::Path) {
for args in &[
vec!["init", "-b", "main"],
vec!["config", "user.name", "Test"],
vec!["config", "user.email", "test@test.com"],
vec!["config", "commit.gpgsign", "false"],
] {
std::process::Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap();
}
}
#[test]
fn case_a_squash_merged_pr_cache_no_worktree_base() {
let _g = env_lock();
let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
std::env::set_var(
"GW_TEST_GH_JSON",
r#"[{"headRefName":"fix-squash-branch","state":"MERGED"}]"#,
);
let tmp_repo =
std::path::PathBuf::from(format!("/tmp/gw-test-unit-a-{}", std::process::id()));
let cache = PrCache::load_or_fetch(&tmp_repo, true);
assert_eq!(
cache.state("fix-squash-branch"),
Some(&super::super::pr_cache::PrState::Merged),
"PrCache must report Merged for the test to be meaningful"
);
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
let result = branch_is_merged("fix-squash-branch", repo, &cache);
assert!(
result.is_some(),
"branch_is_merged must return Some(base) when PrCache reports MERGED, \
even without a worktreeBase git config entry (the live bug)"
);
}
#[test]
fn case_c_no_pr_not_merged_no_worktree_base() {
let _g = env_lock();
let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
std::env::set_var("GW_TEST_GH_FAIL", "1");
let tmp_repo =
std::path::PathBuf::from(format!("/tmp/gw-test-unit-c-{}", std::process::id()));
let cache = PrCache::load_or_fetch(&tmp_repo, true);
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("README.md"), "hi").unwrap();
for args in &[vec!["add", "."], vec!["commit", "-m", "init"]] {
std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
}
std::process::Command::new("git")
.args(["checkout", "-b", "feat-unmerged"])
.current_dir(repo)
.output()
.unwrap();
std::fs::write(repo.join("feat.txt"), "work").unwrap();
for args in &[vec!["add", "."], vec!["commit", "-m", "feat work"]] {
std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
}
let result = branch_is_merged("feat-unmerged", repo, &cache);
assert!(
result.is_none(),
"branch_is_merged must return None for an unmerged branch with no PR \
and no worktreeBase config"
);
}
#[test]
fn reason_base_matches_resolved_worktree_base_config() {
let _g = env_lock();
let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
std::env::set_var(
"GW_TEST_GH_JSON",
r#"[{"headRefName":"some-feature","state":"MERGED"}]"#,
);
let tmp_repo =
std::path::PathBuf::from(format!("/tmp/gw-test-unit-reason-{}", std::process::id()));
let cache = PrCache::load_or_fetch(&tmp_repo, true);
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::process::Command::new("git")
.args(["config", "branch.some-feature.worktreeBase", "develop"])
.current_dir(repo)
.output()
.unwrap();
let result = branch_is_merged("some-feature", repo, &cache);
assert_eq!(
result.as_deref(),
Some("develop"),
"branch_is_merged must return the worktreeBase config value as the \
resolved base, so the user-facing 'merged into <base>' reason \
cannot drift from what the predicate actually checked"
);
}
#[test]
fn pr_open_is_not_merged() {
let _g = env_lock();
let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
std::env::set_var(
"GW_TEST_GH_JSON",
r#"[{"headRefName":"feat-open","state":"OPEN"}]"#,
);
let tmp_repo =
std::path::PathBuf::from(format!("/tmp/gw-test-unit-open-{}", std::process::id()));
let cache = PrCache::load_or_fetch(&tmp_repo, true);
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
let result = branch_is_merged("feat-open", repo, &cache);
assert!(result.is_none(), "An OPEN PR must not be considered merged");
}
}