Skip to main content

open_loops/
worktrees.rs

1//! Worktree inventory: joins `git worktree list` with merged/idle/state signals.
2use crate::scanner::{default_branch, find_repos, git, parse_worktree_porcelain};
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8/// Cleanup classification of a worktree.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Verdict {
11    /// Main worktree (default checkout). Never deletable.
12    Home,
13    /// Directory gone / orphaned — cleared by `git worktree prune`.
14    Prunable,
15    /// Uncommitted changes or no clear branch. Live work.
16    Active,
17    /// Merged into default and clean — disk clutter.
18    Deletable,
19    /// Not merged and clean — review candidate.
20    Cold,
21}
22
23impl Verdict {
24    pub fn label(&self) -> &'static str {
25        match self {
26            Verdict::Home => "home",
27            Verdict::Prunable => "prunable",
28            Verdict::Active => "active",
29            Verdict::Deletable => "deletable",
30            Verdict::Cold => "cold",
31        }
32    }
33}
34
35/// A repository worktree.
36#[derive(Debug, Clone)]
37pub struct Worktree {
38    pub repo_name: String,
39    pub repo_path: PathBuf,
40    pub worktree_path: PathBuf,
41    pub branch: Option<String>,
42    pub last_commit: Option<DateTime<Utc>>,
43    pub merged: bool,
44    pub dirty: bool,
45    pub prunable: bool,
46    pub is_main: bool,
47}
48
49impl Worktree {
50    /// Deterministic verdict; first matching rule wins.
51    pub fn verdict(&self) -> Verdict {
52        if self.is_main {
53            return Verdict::Home;
54        }
55        if self.prunable {
56            return Verdict::Prunable;
57        }
58        if self.dirty {
59            return Verdict::Active;
60        }
61        match self.branch {
62            None => Verdict::Active, // detached but clean — safe default
63            Some(_) if self.merged => Verdict::Deletable,
64            Some(_) => Verdict::Cold,
65        }
66    }
67
68    /// Short table name: `repo/<worktree-basename>`.
69    pub fn short_name(&self) -> String {
70        let base = self
71            .worktree_path
72            .file_name()
73            .map(|n| n.to_string_lossy().into_owned())
74            .unwrap_or_else(|| self.worktree_path.display().to_string());
75        format!("{}/{}", self.repo_name, base)
76    }
77}
78
79/// Enumerates and classifies a repository's worktrees.
80///
81/// # Errors
82///
83/// Returns `Err` if `git worktree list` fails.
84pub fn worktrees(repo: &Path) -> Result<Vec<Worktree>> {
85    let raw = git(repo, &["worktree", "list", "--porcelain"])?;
86    let default = default_branch(repo).ok();
87    let merged_set: HashSet<String> = match &default {
88        Some(d) => git(
89            repo,
90            &["branch", "--merged", d, "--format=%(refname:short)"],
91        )
92        .unwrap_or_default()
93        .lines()
94        .map(|s| s.trim().to_string())
95        // drop the default branch itself: "merged" means merged INTO default
96        .filter(|b| !b.is_empty() && b != d)
97        .collect(),
98        None => HashSet::new(),
99    };
100    let repo_name = repo
101        .file_name()
102        .map(|n| n.to_string_lossy().into_owned())
103        .unwrap_or_else(|| repo.display().to_string());
104
105    let mut out = Vec::new();
106    let mut first = true;
107    for entry in parse_worktree_porcelain(&raw) {
108        if entry.bare {
109            continue;
110        }
111        let wt_path = entry.path;
112        let branch = entry.branch;
113        let prunable = entry.prunable;
114        let is_main = first;
115        first = false;
116
117        let (last_commit, dirty) = if prunable {
118            (None, false)
119        } else {
120            let lc = git(&wt_path, &["log", "-1", "--format=%cI"])
121                .ok()
122                .and_then(|s| DateTime::parse_from_rfc3339(s.trim()).ok())
123                .map(|d| d.with_timezone(&Utc));
124            let status = git(&wt_path, &["status", "--porcelain"]).unwrap_or_default();
125            (lc, !status.trim().is_empty())
126        };
127        let merged = branch
128            .as_ref()
129            .map(|b| merged_set.contains(b))
130            .unwrap_or(false);
131
132        out.push(Worktree {
133            repo_name: repo_name.clone(),
134            repo_path: repo.to_path_buf(),
135            worktree_path: wt_path,
136            branch,
137            last_commit,
138            merged,
139            dirty,
140            prunable,
141            is_main,
142        });
143    }
144    Ok(out)
145}
146
147/// Scans worktrees of all repos found under the roots, in parallel.
148///
149/// Per-repo failures become warnings, never abort.
150pub fn scan_worktrees(roots: &[PathBuf], scan_depth: usize) -> (Vec<Worktree>, Vec<String>) {
151    let (repos, mut warnings) = find_repos(roots, scan_depth);
152    let results: Vec<Result<Vec<Worktree>>> = std::thread::scope(|s| {
153        let handles: Vec<_> = repos
154            .iter()
155            .map(|r| {
156                let path = r.path.clone();
157                s.spawn(move || worktrees(&path))
158            })
159            .collect();
160        handles
161            .into_iter()
162            .map(|h| {
163                h.join()
164                    .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning worktrees")))
165            })
166            .collect()
167    });
168    let mut all = Vec::new();
169    for (repo, res) in repos.iter().zip(results) {
170        match res {
171            Ok(mut w) => all.append(&mut w),
172            Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
173        }
174    }
175    (all, warnings)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::testutil;
182
183    fn wt(
184        branch: Option<&str>,
185        merged: bool,
186        dirty: bool,
187        prunable: bool,
188        is_main: bool,
189    ) -> Worktree {
190        Worktree {
191            repo_name: "app".into(),
192            repo_path: PathBuf::from("/tmp/app"),
193            worktree_path: PathBuf::from("/tmp/app/.wt/x"),
194            branch: branch.map(|b| b.into()),
195            last_commit: None,
196            merged,
197            dirty,
198            prunable,
199            is_main,
200        }
201    }
202
203    #[test]
204    fn verdict_covers_all_combinations() {
205        assert_eq!(
206            wt(Some("main"), true, false, false, true).verdict(),
207            Verdict::Home
208        );
209        assert_eq!(
210            wt(Some("x"), false, false, true, false).verdict(),
211            Verdict::Prunable
212        );
213        assert_eq!(
214            wt(Some("x"), false, true, false, false).verdict(),
215            Verdict::Active
216        );
217        assert_eq!(
218            wt(Some("x"), true, false, false, false).verdict(),
219            Verdict::Deletable
220        );
221        assert_eq!(
222            wt(Some("x"), false, false, false, false).verdict(),
223            Verdict::Cold
224        );
225        // detached clean -> active
226        assert_eq!(
227            wt(None, false, false, false, false).verdict(),
228            Verdict::Active
229        );
230        // is_main beats prunable/dirty
231        assert_eq!(
232            wt(Some("main"), false, true, true, true).verdict(),
233            Verdict::Home
234        );
235    }
236
237    #[test]
238    fn short_name_uses_basename() {
239        let w = wt(Some("x"), false, false, false, false);
240        assert_eq!(w.short_name(), "app/x");
241    }
242
243    #[test]
244    fn worktrees_classifies_deletable_cold_and_dirty() {
245        let tmp = tempfile::tempdir().unwrap();
246        let repo = tmp.path().join("app");
247        testutil::init_repo(&repo);
248
249        // deletable: new branch off main (merged), clean worktree
250        let del = tmp.path().join("wt-del");
251        testutil::add_worktree(&repo, &del, "feat/done");
252
253        // cold: branch with its own commit (unmerged), clean worktree
254        let cold = tmp.path().join("wt-cold");
255        testutil::add_worktree(&repo, &cold, "feat/cold");
256        std::fs::write(cold.join("c.txt"), "c").unwrap();
257        testutil::git(&cold, &["add", "."]);
258        testutil::git(&cold, &["commit", "-m", "wip cold"]);
259
260        // active (dirty): new branch off main with an uncommitted file
261        let dirty = tmp.path().join("wt-dirty");
262        testutil::add_worktree(&repo, &dirty, "feat/dirty");
263        std::fs::write(dirty.join("d.txt"), "d").unwrap();
264
265        let all = worktrees(&repo).unwrap();
266        let by_branch = |b: &str| {
267            all.iter()
268                .find(|w| w.branch.as_deref() == Some(b))
269                .unwrap_or_else(|| panic!("branch {b} missing"))
270        };
271        assert_eq!(by_branch("feat/done").verdict(), Verdict::Deletable);
272        assert_eq!(by_branch("feat/cold").verdict(), Verdict::Cold);
273        assert_eq!(by_branch("feat/dirty").verdict(), Verdict::Active);
274
275        // main becomes home
276        let main = all.iter().find(|w| w.is_main).expect("main worktree");
277        assert_eq!(main.verdict(), Verdict::Home);
278    }
279
280    #[test]
281    fn scan_worktrees_aggregates_and_does_not_abort() {
282        let tmp = tempfile::tempdir().unwrap();
283        let repo = tmp.path().join("app");
284        testutil::init_repo(&repo);
285        let extra = tmp.path().join("wt-extra");
286        testutil::add_worktree(&repo, &extra, "feat/extra");
287
288        let (all, warnings) = scan_worktrees(&[tmp.path().to_path_buf()], 4);
289        assert!(all
290            .iter()
291            .any(|w| w.branch.as_deref() == Some("feat/extra")));
292        assert!(warnings.is_empty());
293    }
294}