git_worktree_cli/commands/
remove.rs1use 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 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 let git_dir = find_git_directory()?;
25
26 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 let target_worktree = find_target_worktree(&worktrees, branch_name)?;
36
37 if target_worktree.bare {
39 return Err(Error::msg("Cannot remove the main (bare) repository."));
40 }
41
42 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 println!("{}", "About to remove worktree:".cyan().bold());
53 println!(" {}: {}", "Path".dimmed(), target_worktree.path.display());
54 println!(" {}: {}", "Branch".dimmed(), branch_display.green());
55
56 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 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 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 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 let main_branches = constants::PROTECTED_BRANCHES;
101 let git_working_dir = worktrees
102 .iter()
103 .find(|wt| {
104 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 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 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 if !main_branches.contains(&branch_display) {
135 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 e.to_string().contains("not fully merged") {
143 println!(
144 "{}",
145 format!("⚠️ Branch '{}' has unmerged changes", branch_display).yellow()
146 );
147
148 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 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 will_remove_current {
197 std::env::set_current_dir(&project_root)?;
198 }
199
200 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 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 if let Some(worktree) = find_by_branch_name(worktrees, target_branch) {
239 return Ok(worktree);
240 }
241
242 if let Some(worktree) = find_by_path_name(worktrees, target_branch) {
244 return Ok(worktree);
245 }
246
247 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
299fn remove_orphaned_worktree(worktree_path: &std::path::Path, branch_name: &str, force: bool) -> Result<()> {
301 use std::fs;
302
303 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 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 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 will_remove_current {
339 std::env::set_current_dir(&project_root)?;
340 }
341
342 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 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}