git_worktree_cli/commands/
remove.rs

1use colored::Colorize;
2use std::io::{self, Write};
3
4use crate::{
5    constants,
6    core::project::{clean_branch_name, find_git_directory, find_project_root_from, is_orphaned_worktree, find_valid_git_directory, find_project_root},
7    error::{Error, Result},
8    git, hooks,
9};
10
11pub fn run(branch_name: Option<&str>, force: bool) -> Result<()> {
12    // Check if we're trying to remove an orphaned worktree by directory name
13    if let Some(branch) = branch_name {
14        if let Ok(project_root) = find_project_root() {
15            let potential_worktree_path = project_root.join(branch);
16            if is_orphaned_worktree(&potential_worktree_path) {
17                println!("{}", "⚠️  Detected orphaned worktree (stale git reference)".yellow());
18                return remove_orphaned_worktree(&potential_worktree_path, branch, force);
19            }
20        }
21    }
22
23    // Find a git directory to work with
24    let git_dir = find_git_directory()?;
25
26    // Get the list of worktrees
27    let worktrees = git::list_worktrees(Some(&git_dir))?;
28
29    if worktrees.is_empty() {
30        println!("{}", "No worktrees found.".yellow());
31        return Ok(());
32    }
33
34    // Find the worktree to remove
35    let target_worktree = find_target_worktree(&worktrees, branch_name)?;
36
37    // Check if this is the bare repository
38    if target_worktree.bare {
39        return Err(Error::msg("Cannot remove the main (bare) repository."));
40    }
41
42    // Check if target worktree is orphaned (after finding it in the list)
43    if is_orphaned_worktree(&target_worktree.path) {
44        let branch_display = get_branch_display(target_worktree);
45        println!("{}", "⚠️  Detected orphaned worktree (stale git reference)".yellow());
46        return remove_orphaned_worktree(&target_worktree.path, branch_display, force);
47    }
48
49    let branch_display = get_branch_display(target_worktree);
50
51    // Show what will be removed
52    println!("{}", "About to remove worktree:".cyan().bold());
53    println!("  {}: {}", "Path".dimmed(), target_worktree.path.display());
54    println!("  {}: {}", "Branch".dimmed(), branch_display.green());
55
56    // Check if we're currently in the worktree being removed
57    let current_dir = std::env::current_dir()?;
58    let will_remove_current = current_dir.starts_with(&target_worktree.path);
59
60    if will_remove_current {
61        println!(
62            "\n{}",
63            "⚠️  You are currently in this worktree. You will be moved to the project root after removal.".yellow()
64        );
65    }
66
67    // Ask for confirmation unless --force is used
68    if !force {
69        print!("\n{}", "Are you sure you want to remove this worktree? (y/N): ".cyan());
70        io::stdout().flush()?;
71
72        let mut input = String::new();
73        io::stdin().read_line(&mut input)?;
74        let confirmation = input.trim().to_lowercase();
75
76        if confirmation != "y" && confirmation != "yes" {
77            println!("{}", "Removal cancelled.".yellow());
78            return Ok(());
79        }
80    }
81
82    // Find project root from the worktree being removed (go up one level)
83    let project_root = if let Some(parent) = target_worktree.path.parent() {
84        find_project_root_from(parent)?
85    } else {
86        find_project_root_from(&target_worktree.path)?
87    };
88
89    // Execute pre-remove hooks before any removal operations (run from worktree directory)
90    hooks::execute_hooks(
91        "preRemove",
92        &target_worktree.path,
93        &[
94            ("branchName", branch_display),
95            ("worktreePath", target_worktree.path.to_str().unwrap()),
96        ],
97    )?;
98
99    // Find another worktree to run git commands from
100    let main_branches = constants::PROTECTED_BRANCHES;
101    let git_working_dir = worktrees
102        .iter()
103        .find(|wt| {
104            // Try to find a main branch first
105            wt.path != target_worktree.path
106                && wt
107                    .branch
108                    .as_ref()
109                    .map(|b| {
110                        let clean_branch = b.strip_prefix("refs/heads/").unwrap_or(b);
111                        main_branches.contains(&clean_branch)
112                    })
113                    .unwrap_or(false)
114        })
115        .or_else(|| {
116            // If no main branch, use any other worktree
117            worktrees.iter().find(|wt| wt.path != target_worktree.path)
118        })
119        .ok_or_else(|| Error::msg("No other worktrees found to execute git command from."))?;
120
121    // Remove the worktree
122    println!("\n{}", "Removing worktree...".cyan());
123    git::execute_streaming(
124        &["worktree", "remove", target_worktree.path.to_str().unwrap(), "--force"],
125        Some(&git_working_dir.path),
126    )?;
127
128    println!(
129        "{}",
130        format!("✓ Worktree removed: {}", target_worktree.path.display()).green()
131    );
132
133    // Delete the branch if it's not a main branch
134    if !main_branches.contains(&branch_display) {
135        // First try to delete the branch normally
136        match git::execute_capture(&["branch", "-d", branch_display], Some(&git_working_dir.path)) {
137            Ok(_) => {
138                println!("{}", format!("✓ Branch deleted: {}", branch_display).green());
139            }
140            Err(e) => {
141                // If normal deletion fails, check if it's because of unmerged changes
142                if e.to_string().contains("not fully merged") {
143                    println!(
144                        "{}",
145                        format!("⚠️  Branch '{}' has unmerged changes", branch_display).yellow()
146                    );
147
148                    // Ask for confirmation to force delete unless --force is used
149                    let should_force_delete = if force {
150                        true
151                    } else {
152                        print!("{}", "Force delete the branch? (y/N): ".cyan());
153                        io::stdout().flush()?;
154
155                        let mut input = String::new();
156                        io::stdin().read_line(&mut input)?;
157                        let force_delete = input.trim().to_lowercase();
158                        force_delete == "y" || force_delete == "yes"
159                    };
160
161                    if should_force_delete {
162                        match git::execute_streaming(&["branch", "-D", branch_display], Some(&git_working_dir.path)) {
163                            Ok(_) => {
164                                println!("{}", format!("✓ Branch force deleted: {}", branch_display).green());
165                            }
166                            Err(e) => {
167                                println!(
168                                    "{}",
169                                    format!("❌ Failed to delete branch '{}': {}", branch_display, e).red()
170                                );
171                            }
172                        }
173                    } else {
174                        println!(
175                            "{}",
176                            format!("⚠️  Branch '{}' was not deleted", branch_display).yellow()
177                        );
178                    }
179                } else {
180                    // Some other error occurred
181                    println!(
182                        "{}",
183                        format!("❌ Failed to delete branch '{}': {}", branch_display, e).red()
184                    );
185                }
186            }
187        }
188    } else {
189        println!(
190            "{}",
191            format!("✓ Branch: {} (preserved - main branch)", branch_display).green()
192        );
193    }
194
195    // If we removed the current worktree, change to project root before executing hooks
196    if will_remove_current {
197        std::env::set_current_dir(&project_root)?;
198    }
199
200    // Execute post-remove hooks
201    hooks::execute_hooks(
202        "postRemove",
203        &project_root,
204        &[
205            ("branchName", branch_display),
206            ("worktreePath", target_worktree.path.to_str().unwrap()),
207        ],
208    )?;
209
210    // If we removed the current worktree, show message about moving to project root
211    if will_remove_current {
212        println!(
213            "{}",
214            format!("✓ Please navigate to project root: {}", project_root.display()).green()
215        );
216    }
217
218    Ok(())
219}
220
221fn find_target_worktree<'a>(worktrees: &'a [git::Worktree], branch_name: Option<&str>) -> Result<&'a git::Worktree> {
222    match branch_name {
223        None => find_current_worktree(worktrees),
224        Some(target_branch) => find_worktree_by_branch(worktrees, target_branch),
225    }
226}
227
228fn find_current_worktree(worktrees: &[git::Worktree]) -> Result<&git::Worktree> {
229    let current_dir = std::env::current_dir()?;
230    worktrees
231        .iter()
232        .find(|wt| current_dir.starts_with(&wt.path))
233        .ok_or_else(|| Error::msg("Not in a git worktree. Please specify a branch to remove."))
234}
235
236fn find_worktree_by_branch<'a>(worktrees: &'a [git::Worktree], target_branch: &str) -> Result<&'a git::Worktree> {
237    // First try to find by branch name
238    if let Some(worktree) = find_by_branch_name(worktrees, target_branch) {
239        return Ok(worktree);
240    }
241
242    // Then try to find by path
243    if let Some(worktree) = find_by_path_name(worktrees, target_branch) {
244        return Ok(worktree);
245    }
246
247    // Not found, show available worktrees
248    show_available_worktrees(worktrees);
249    Err(Error::msg(format!("Worktree for '{}' not found", target_branch)))
250}
251
252fn find_by_branch_name<'a>(worktrees: &'a [git::Worktree], target_branch: &str) -> Option<&'a git::Worktree> {
253    worktrees.iter().find(|wt| {
254        wt.branch
255            .as_ref()
256            .map(|b| clean_branch_name(b) == target_branch)
257            .unwrap_or(false)
258    })
259}
260
261fn find_by_path_name<'a>(worktrees: &'a [git::Worktree], target_branch: &str) -> Option<&'a git::Worktree> {
262    worktrees.iter().find(|wt| {
263        wt.path
264            .file_name()
265            .and_then(|name| name.to_str())
266            .map(|name| name == target_branch)
267            .unwrap_or(false)
268    })
269}
270
271fn show_available_worktrees(worktrees: &[git::Worktree]) {
272    println!("{}", "Error: Worktree not found.".red());
273    println!("\n{}", "Available worktrees:".yellow());
274
275    for worktree in worktrees {
276        let branch_display = get_branch_display(worktree);
277        println!(
278            "  {} -> {}",
279            branch_display.green(),
280            worktree.path.display().to_string().dimmed()
281        );
282    }
283}
284
285fn get_branch_display(worktree: &git::Worktree) -> &str {
286    worktree
287        .branch
288        .as_ref()
289        .map(|b| clean_branch_name(b))
290        .unwrap_or_else(|| {
291            if worktree.bare {
292                "(bare)"
293            } else {
294                &worktree.head[..8.min(worktree.head.len())]
295            }
296        })
297}
298
299/// Remove an orphaned worktree (one with a stale git reference)
300fn remove_orphaned_worktree(worktree_path: &std::path::Path, branch_name: &str, force: bool) -> Result<()> {
301    use std::fs;
302
303    // Show what will be removed
304    println!("{}", "About to remove orphaned worktree:".cyan().bold());
305    println!("  {}: {}", "Path".dimmed(), worktree_path.display());
306    println!("  {}: {}", "Name".dimmed(), branch_name.green());
307    println!("  {}: {}", "Status".dimmed(), "Orphaned (stale reference)".yellow());
308
309    // Check if we're currently in the worktree being removed
310    let current_dir = std::env::current_dir()?;
311    let will_remove_current = current_dir.starts_with(worktree_path);
312
313    if will_remove_current {
314        println!(
315            "\n{}",
316            "⚠️  You are currently in this worktree. You will be moved to the project root after removal.".yellow()
317        );
318    }
319
320    // Ask for confirmation unless --force is used
321    if !force {
322        print!("\n{}", "Are you sure you want to remove this orphaned worktree? (y/N): ".cyan());
323        io::stdout().flush()?;
324
325        let mut input = String::new();
326        io::stdin().read_line(&mut input)?;
327        let confirmation = input.trim().to_lowercase();
328
329        if confirmation != "y" && confirmation != "yes" {
330            println!("{}", "Removal cancelled.".yellow());
331            return Ok(());
332        }
333    }
334
335    let project_root = find_project_root()?;
336
337    // If we're currently in the worktree being removed, change directory first
338    if will_remove_current {
339        std::env::set_current_dir(&project_root)?;
340    }
341
342    // Remove the directory
343    println!("\n{}", "Removing orphaned worktree directory...".cyan());
344    fs::remove_dir_all(worktree_path).map_err(|e| {
345        Error::msg(format!(
346            "Failed to remove directory {}: {}",
347            worktree_path.display(),
348            e
349        ))
350    })?;
351
352    println!(
353        "{}",
354        format!("✓ Directory removed: {}", worktree_path.display()).green()
355    );
356
357    // Try to prune worktree references from a valid git directory
358    if let Ok(valid_git_dir) = find_valid_git_directory(&project_root) {
359        println!("{}", "Pruning stale worktree references...".cyan());
360        match git::prune_worktrees(&valid_git_dir) {
361            Ok(_) => {
362                println!("{}", "✓ Worktree references pruned".green());
363            }
364            Err(e) => {
365                println!(
366                    "{}",
367                    format!("⚠️  Failed to prune worktree references: {}", e).yellow()
368                );
369            }
370        }
371    }
372
373    if will_remove_current {
374        println!(
375            "{}",
376            format!("✓ Moved to project root: {}", project_root.display()).green()
377        );
378    }
379
380    println!("\n{}", "Note: Orphaned worktree removed. Hooks were skipped due to invalid git state.".dimmed());
381
382    Ok(())
383}