use console::style;
use std::path::{Path, PathBuf};
use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
use crate::error::Result;
use crate::git;
use crate::messages;
use crate::operations::delete_batch;
use crate::operations::worktree::DeleteFlags;
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(
merged: bool,
older_than: Option<u64>,
dry_run: bool,
force: bool,
interactive: bool,
) -> Result<i32> {
if interactive {
eprintln!(
"Error: `gw clean -i` has been removed.\n\
For interactive selection, use `gw delete -i` instead."
);
return Ok(2);
}
if !merged && older_than.is_none() {
eprintln!(
"Error: `gw clean` requires at least one filter:\n \
--merged, --older-than <DURATION>.\n\
For interactive selection, use `gw delete -i`."
);
return Ok(2);
}
let main_repo = git::get_repo_root(None)?;
let candidates = git::get_feature_worktrees(Some(&main_repo))?;
let pr_cache = PrCache::load_or_fetch(&main_repo, false);
let is_merged_pred =
|branch: &str| -> bool { branch_is_merged(branch, &main_repo, &pr_cache).is_some() };
let is_old_pred = |path: &Path| -> bool {
match older_than {
Some(days) => path_age_days(path)
.map(|age| age as u64 >= days)
.unwrap_or(false),
None => false,
}
};
let selected = filter_worktrees_pure(
&candidates,
merged,
older_than,
&is_merged_pred,
&is_old_pred,
);
if selected.is_empty() {
println!(
"{} No worktrees match the cleanup criteria",
style("*").green().bold()
);
return Ok(0);
}
let flags = DeleteFlags {
keep_branch: false,
delete_remote: false,
git_force: true,
allow_busy: force,
};
let count = selected.len() as u32;
let code = delete_batch::delete_worktrees(
selected, false, dry_run, flags, None,
)?;
if code != 1 {
println!("{}", style("Pruning stale worktree metadata...").dim());
let _ = git::git_command(&["worktree", "prune"], Some(&main_repo), false, false);
println!("{}", style("* Prune complete").dim());
}
if code == 0 && !dry_run {
println!(
"\n{}",
style(messages::cleanup_complete(count)).green().bold()
);
}
Ok(code)
}
pub(crate) fn filter_worktrees_pure(
candidates: &[(String, PathBuf)],
merged: bool,
older_than_days: Option<u64>,
is_merged: &dyn Fn(&str) -> bool,
is_old: &dyn Fn(&Path) -> bool,
) -> Vec<String> {
if !merged && older_than_days.is_none() {
return Vec::new();
}
candidates
.iter()
.filter(|(branch, path)| {
let m = merged && is_merged(branch);
let o = older_than_days.is_some() && is_old(path);
m || o
})
.map(|(branch, _)| branch.clone())
.collect()
}
#[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");
}
#[test]
fn filter_worktrees_pure_selects_union_of_matching_rules() {
let candidates: Vec<(String, std::path::PathBuf)> = vec![
(
"feat/merged-only".into(),
std::path::PathBuf::from("/tmp/a"),
),
("feat/old-only".into(), std::path::PathBuf::from("/tmp/b")),
("feat/both".into(), std::path::PathBuf::from("/tmp/c")),
("feat/neither".into(), std::path::PathBuf::from("/tmp/d")),
];
let is_merged =
|branch: &str| -> bool { matches!(branch, "feat/merged-only" | "feat/both") };
let is_old = |path: &std::path::Path| -> bool {
matches!(path.to_str().unwrap_or(""), "/tmp/b" | "/tmp/c")
};
let selected = super::filter_worktrees_pure(
&candidates,
true,
Some(30),
&is_merged,
&is_old,
);
assert_eq!(
selected,
vec![
"feat/merged-only".to_string(),
"feat/old-only".to_string(),
"feat/both".to_string(),
]
);
}
#[test]
fn filter_worktrees_pure_empty_when_no_filters_active() {
let candidates: Vec<(String, std::path::PathBuf)> =
vec![("x".into(), std::path::PathBuf::from("/tmp/x"))];
let is_merged = |_: &str| true;
let is_old = |_: &std::path::Path| true;
let selected = super::filter_worktrees_pure(
&candidates,
false,
None,
&is_merged,
&is_old,
);
assert!(selected.is_empty());
}
}