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.file_type().is_ok_and(|t| t.is_dir()) {
44            continue;
45        }
46        let Ok(branch_dirs) = std::fs::read_dir(project_entry.path()) else {
47            continue;
48        };
49        for branch_entry in branch_dirs {
50            let branch_entry = match branch_entry {
51                Ok(e) => e,
52                Err(e) => {
53                    eprintln!("error reading project directory entry: {e}");
54                    continue;
55                }
56            };
57            if !branch_entry.file_type().is_ok_and(|t| t.is_dir()) {
58                continue;
59            }
60            let wt_path = branch_entry.path().to_string_lossy().to_string();
61            if owned.contains(&wt_path) {
62                continue;
63            }
64
65            if dry_run {
66                println!("would remove: {wt_path}");
67                removed += 1;
68                continue;
69            }
70
71            let ok = cleanup_orphaned_worktree("prune", Path::new(&wt_path)).await;
72            if ok {
73                println!("removed: {wt_path}");
74                removed += 1;
75            } else {
76                eprintln!("failed to remove: {wt_path}");
77                failed += 1;
78            }
79        }
80    }
81
82    if removed == 0 && failed == 0 {
83        println!("No orphaned worktrees found.");
84        return Ok(());
85    }
86
87    if dry_run {
88        println!("\n{removed} orphaned worktree(s) would be removed (dry run)");
89    } else {
90        println!(
91            "\nRemoved {} orphaned worktree(s){}",
92            removed,
93            if failed > 0 {
94                format!(", {failed} failed")
95            } else {
96                String::new()
97            }
98        );
99    }
100
101    Ok(())
102}