Skip to main content

orch/cli/
prune.rs

1use crate::engine::cleanup::cleanup_orphaned_worktree;
2use std::path::Path;
3use std::sync::Arc;
4
5/// Remove orphaned worktrees — directories under `~/.orch/worktrees/` that are
6/// not referenced by any task in the store.
7///
8/// Unlike `orch service doctor --fix`, this command does not require `--fix` to be
9/// specified and does not prompt for confirmation. Orphaned worktrees are
10/// unreferenced by definition and safe to remove automatically.
11pub async fn run(dry_run: bool) -> anyhow::Result<()> {
12    let store = Arc::new(crate::cli::init_store().await?);
13    let all_tasks = store.list_all_global().await?;
14
15    let worktrees_dir = crate::home::worktrees_dir()?;
16
17    // Build a set of owned worktree paths for O(1) lookup.
18    let owned: std::collections::HashSet<String> = all_tasks
19        .iter()
20        .filter(|t| !t.worktree.is_empty())
21        .map(|t| t.worktree.clone())
22        .collect();
23
24    let mut removed = 0usize;
25    let mut failed = 0usize;
26
27    let project_dirs = match std::fs::read_dir(&worktrees_dir) {
28        Ok(d) => d,
29        Err(e) => {
30            eprintln!("cannot read worktrees directory: {e}");
31            return Ok(());
32        }
33    };
34
35    for project_entry in project_dirs {
36        let project_entry = match project_entry {
37            Ok(e) => e,
38            Err(e) => {
39                eprintln!("error reading worktrees directory entry: {e}");
40                continue;
41            }
42        };
43        if !project_entry
44            .file_type()
45            .map(|t| t.is_dir())
46            .unwrap_or(false)
47        {
48            continue;
49        }
50        let branch_dirs = match std::fs::read_dir(project_entry.path()) {
51            Ok(d) => d,
52            Err(_) => continue,
53        };
54        for branch_entry in branch_dirs {
55            let branch_entry = match branch_entry {
56                Ok(e) => e,
57                Err(e) => {
58                    eprintln!("error reading project directory entry: {e}");
59                    continue;
60                }
61            };
62            if !branch_entry
63                .file_type()
64                .map(|t| t.is_dir())
65                .unwrap_or(false)
66            {
67                continue;
68            }
69            let wt_path = branch_entry.path().to_string_lossy().to_string();
70            if owned.contains(&wt_path) {
71                continue;
72            }
73
74            if dry_run {
75                println!("would remove: {}", wt_path);
76                removed += 1;
77                continue;
78            }
79
80            let ok = cleanup_orphaned_worktree("prune", Path::new(&wt_path)).await;
81            if ok {
82                println!("removed: {}", wt_path);
83                removed += 1;
84            } else {
85                eprintln!("failed to remove: {}", wt_path);
86                failed += 1;
87            }
88        }
89    }
90
91    if removed == 0 && failed == 0 {
92        println!("No orphaned worktrees found.");
93        return Ok(());
94    }
95
96    if dry_run {
97        println!(
98            "\n{} orphaned worktree(s) would be removed (dry run)",
99            removed
100        );
101    } else {
102        println!(
103            "\nRemoved {} orphaned worktree(s){}",
104            removed,
105            if failed > 0 {
106                format!(", {} failed", failed)
107            } else {
108                String::new()
109            }
110        );
111    }
112
113    Ok(())
114}