use std::io::stdout;
use std::time::{Duration, Instant};
use crossterm::{
event::{Event, KeyCode, KeyModifiers},
terminal::{disable_raw_mode, enable_raw_mode},
};
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
use crate::cli::RemoveArgs;
use crate::config::{load_config, CleanBranchMode};
use crate::error::Result;
use crate::git::{
delete_local_branch, get_worktrees_with_details, is_branch_merged, remove_worktree,
WorktreeStatus,
};
use crate::ui::colors::{GREEN, RED, RESET, YELLOW};
use crate::ui::event::{is_cancel_key, poll_event};
use crate::ui::summary::print_remove_summary;
use crate::ui::widgets::{MultiSelectItem, MultiSelectListWidget, MultiSelectState};
use crate::ui::{SelectItemMetadata, TextInputState};
const TUI_INLINE_HEIGHT: u16 = 35;
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
if let Err(e) = disable_raw_mode() {
eprintln!("{YELLOW} Warning: Failed to restore terminal: {e}{RESET}");
}
}
}
pub fn run_remove(args: RemoveArgs) -> Result<()> {
let config = load_config();
let worktrees = get_worktrees_with_details()?;
let items: Vec<MultiSelectItem> = worktrees
.iter()
.map(|wt| {
let label = wt.display_branch().to_string();
let value = wt.path.display().to_string();
let metadata = SelectItemMetadata {
last_commit_date: wt.commit_date.clone().unwrap_or_default(),
last_committer_name: wt.committer_name.clone().unwrap_or_default(),
last_commit_message: wt.commit_message.clone().unwrap_or_default(),
sync_status: wt.sync_status.clone(),
change_status: wt.change_status.clone(),
};
let item = MultiSelectItem::new(label, value)
.with_description(wt.path.display().to_string())
.with_metadata(metadata);
match wt.status {
WorktreeStatus::Main => item.disabled("MAIN"),
WorktreeStatus::Active => item.disabled("ACTIVE"),
WorktreeStatus::Other => item,
}
})
.collect();
if items.is_empty() {
println!("No worktrees found.");
return Ok(());
}
let selectable_count = items.iter().filter(|i| !i.disabled).count();
if selectable_count == 0 {
println!("No worktrees available for removal.");
println!("(MAIN and ACTIVE worktrees cannot be removed)");
return Ok(());
}
if let Some(ref query) = args.query {
if let Some(item) = find_exact_match(&items, query) {
if item.disabled {
let reason = item.disabled_reason.as_deref().unwrap_or("This");
return Err(crate::error::GwmError::invalid_argument(format!(
"Cannot remove '{}': {} worktree cannot be removed",
item.label, reason
)));
}
let clean_branch_mode = args.clean_branch.unwrap_or(config.clean_branch);
return execute_remove(
std::slice::from_ref(item),
&config.main_branches,
clean_branch_mode,
args.force,
);
}
}
let selected_items = run_remove_tui(&items, args.query.as_deref())?;
if selected_items.is_empty() {
println!("No worktrees selected.");
return Ok(());
}
let clean_branch_mode = args.clean_branch.unwrap_or(config.clean_branch);
execute_remove(
&selected_items,
&config.main_branches,
clean_branch_mode,
args.force,
)
}
fn run_remove_tui(
items: &[MultiSelectItem],
initial_query: Option<&str>,
) -> Result<Vec<MultiSelectItem>> {
enable_raw_mode()?;
let _guard = TerminalGuard;
let stdout = stdout();
let backend = CrosstermBackend::new(stdout);
let options = TerminalOptions {
viewport: Viewport::Inline(TUI_INLINE_HEIGHT),
};
let mut terminal = Terminal::with_options(backend, options)?;
let (mut input, mut state) = match initial_query {
Some(query) => {
let input = TextInputState::with_value(query.to_string());
let mut state = MultiSelectState::new(items.to_vec());
state.update_filter(query);
(input, state)
}
None => (TextInputState::new(), MultiSelectState::new(items.to_vec())),
};
let result = loop {
terminal.draw(|frame| {
let widget = MultiSelectListWidget::new(
"Remove worktrees",
"Search worktrees...",
&input,
&state,
);
frame.render_widget(widget, frame.area());
})?;
if let Some(Event::Key(key)) = poll_event(Duration::from_millis(100))? {
if is_cancel_key(&key) {
break vec![];
}
match (key.modifiers, key.code) {
(_, KeyCode::Enter) => {
if !state.selected_indices.is_empty() {
break state.selected_items().into_iter().cloned().collect();
}
}
(_, KeyCode::Up) | (KeyModifiers::CONTROL, KeyCode::Char('p')) => {
state.move_up();
}
(_, KeyCode::Down) | (KeyModifiers::CONTROL, KeyCode::Char('n')) => {
state.move_down();
}
(_, KeyCode::Char(' ')) => {
state.toggle_current();
}
(KeyModifiers::CONTROL, KeyCode::Char('a')) => {
state.toggle_all();
}
(KeyModifiers::CONTROL, KeyCode::Char('u')) => {
input.clear();
state.update_filter(&input.value);
}
(KeyModifiers::CONTROL, KeyCode::Char('w'))
| (KeyModifiers::ALT, KeyCode::Backspace) => {
input.delete_word_backward();
state.update_filter(&input.value);
}
(_, KeyCode::Backspace) => {
input.delete_backward();
state.update_filter(&input.value);
}
(_, KeyCode::Delete) => {
input.delete_forward();
state.update_filter(&input.value);
}
(_, KeyCode::Left) | (KeyModifiers::CONTROL, KeyCode::Char('b')) => {
input.move_left();
}
(_, KeyCode::Right) | (KeyModifiers::CONTROL, KeyCode::Char('f')) => {
input.move_right();
}
(_, KeyCode::Home) => {
input.move_start();
}
(KeyModifiers::CONTROL, KeyCode::Char('e')) | (_, KeyCode::End) => {
input.move_end();
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
input.insert(c);
state.update_filter(&input.value);
}
_ => {}
}
}
};
drop(_guard);
println!();
Ok(result)
}
fn execute_remove(
items: &[MultiSelectItem],
main_branches: &[String],
clean_branch_mode: CleanBranchMode,
force: bool,
) -> Result<()> {
let start = Instant::now();
let mut removed = 0;
let mut failed = 0;
println!("Removing {} worktree(s)...\n", items.len());
for item in items {
let path = std::path::Path::new(&item.value);
let branch = &item.label;
match remove_worktree(path, force) {
Ok(()) => {
println!("{GREEN}✓ Removed worktree: {branch}{RESET}");
handle_branch_cleanup(branch, main_branches, clean_branch_mode);
removed += 1;
}
Err(e) => {
println!("{RED}✗ Failed to remove {branch}: {e}{RESET}");
failed += 1;
}
}
}
print_remove_summary(removed, failed, start.elapsed());
if failed > 0 && removed == 0 {
return Err(crate::error::GwmError::git_command(format!(
"Failed to remove all {} worktree(s)",
failed
)));
}
Ok(())
}
fn handle_branch_cleanup(
branch: &str,
main_branches: &[String],
clean_branch_mode: CleanBranchMode,
) {
match clean_branch_mode {
CleanBranchMode::Auto => {
let is_merged = is_branch_merged(branch, main_branches);
match delete_local_branch(branch, !is_merged) {
Ok(()) => println!(" {GREEN}✓ Deleted local branch: {branch}{RESET}"),
Err(e) => println!(" {YELLOW}Warning: Failed to delete branch: {e}{RESET}"),
}
}
CleanBranchMode::Ask => {
let is_merged = is_branch_merged(branch, main_branches);
let status = if is_merged { "merged" } else { "unmerged" };
print!(" Delete local branch '{branch}' ({status})? [y/N]: ");
use std::io::{self, Write};
let _ = io::stdout().flush();
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
let answer = input.trim().to_lowercase();
if answer == "y" || answer == "yes" {
match delete_local_branch(branch, !is_merged) {
Ok(()) => {
println!(" {GREEN}✓ Deleted local branch: {branch}{RESET}")
}
Err(e) => {
println!(" {YELLOW}Warning: Failed to delete branch: {e}{RESET}")
}
}
} else {
println!(" Skipped branch deletion");
}
}
Err(e) => {
println!(
" {YELLOW}Warning: Could not read input: {e}. Skipping branch deletion.{RESET}"
);
}
}
}
CleanBranchMode::Never => {}
}
}
fn find_exact_match<'a>(items: &'a [MultiSelectItem], query: &str) -> Option<&'a MultiSelectItem> {
let query_lower = query.to_lowercase();
items
.iter()
.find(|item| item.label.to_lowercase() == query_lower)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_item(label: &str, disabled: bool) -> MultiSelectItem {
let item = MultiSelectItem::new(label.to_string(), format!("/path/to/{}", label));
if disabled {
item.disabled("MAIN")
} else {
item
}
}
#[test]
fn test_find_exact_match_case_insensitive() {
let items = vec![
create_test_item("feature/test", false),
create_test_item("main", true),
create_test_item("develop", false),
];
assert!(find_exact_match(&items, "main").is_some());
assert!(find_exact_match(&items, "MAIN").is_some());
assert!(find_exact_match(&items, "Main").is_some());
assert!(find_exact_match(&items, "feature/test").is_some());
assert!(find_exact_match(&items, "FEATURE/TEST").is_some());
}
#[test]
fn test_find_exact_match_partial_no_match() {
let items = vec![
create_test_item("feature/test", false),
create_test_item("feature/test-2", false),
];
assert!(find_exact_match(&items, "feature").is_none());
assert!(find_exact_match(&items, "test").is_none());
assert!(find_exact_match(&items, "feature/test").is_some());
}
#[test]
fn test_find_exact_match_empty_query() {
let items = vec![create_test_item("feature/test", false)];
assert!(find_exact_match(&items, "").is_none());
}
#[test]
fn test_find_exact_match_nonexistent() {
let items = vec![create_test_item("feature/test", false)];
assert!(find_exact_match(&items, "nonexistent").is_none());
}
}