Skip to main content

orch/cli/
prune.rs

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