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};
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 block in raw.split("\n\n") {
108        let block = block.trim();
109        if block.is_empty() {
110            continue;
111        }
112        let mut wt_path: Option<PathBuf> = None;
113        let mut branch: Option<String> = None;
114        let mut prunable = false;
115        let mut bare = false;
116        for line in block.lines() {
117            if let Some(p) = line.strip_prefix("worktree ") {
118                wt_path = Some(PathBuf::from(p));
119            } else if let Some(b) = line.strip_prefix("branch ") {
120                branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
121            } else if line == "bare" {
122                bare = true;
123            } else if line == "prunable" || line.starts_with("prunable ") {
124                prunable = true;
125            }
126            // "detached" => branch stays None
127        }
128        let Some(wt_path) = wt_path else { continue };
129        if bare {
130            continue;
131        }
132        let is_main = first;
133        first = false;
134
135        let (last_commit, dirty) = if prunable {
136            (None, false)
137        } else {
138            let lc = git(&wt_path, &["log", "-1", "--format=%cI"])
139                .ok()
140                .and_then(|s| DateTime::parse_from_rfc3339(s.trim()).ok())
141                .map(|d| d.with_timezone(&Utc));
142            let status = git(&wt_path, &["status", "--porcelain"]).unwrap_or_default();
143            (lc, !status.trim().is_empty())
144        };
145        let merged = branch
146            .as_ref()
147            .map(|b| merged_set.contains(b))
148            .unwrap_or(false);
149
150        out.push(Worktree {
151            repo_name: repo_name.clone(),
152            repo_path: repo.to_path_buf(),
153            worktree_path: wt_path,
154            branch,
155            last_commit,
156            merged,
157            dirty,
158            prunable,
159            is_main,
160        });
161    }
162    Ok(out)
163}
164
165/// Scans worktrees of all repos found under the roots, in parallel.
166///
167/// Per-repo failures become warnings, never abort.
168pub fn scan_worktrees(roots: &[PathBuf], scan_depth: usize) -> (Vec<Worktree>, Vec<String>) {
169    let (repos, mut warnings) = find_repos(roots, scan_depth);
170    let results: Vec<Result<Vec<Worktree>>> = std::thread::scope(|s| {
171        let handles: Vec<_> = repos
172            .iter()
173            .map(|r| s.spawn(move || worktrees(r)))
174            .collect();
175        handles
176            .into_iter()
177            .map(|h| {
178                h.join()
179                    .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning worktrees")))
180            })
181            .collect()
182    });
183    let mut all = Vec::new();
184    for (repo, res) in repos.iter().zip(results) {
185        match res {
186            Ok(mut w) => all.append(&mut w),
187            Err(e) => warnings.push(format!("{}: {e:#}", repo.display())),
188        }
189    }
190    (all, warnings)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::testutil;
197
198    fn wt(
199        branch: Option<&str>,
200        merged: bool,
201        dirty: bool,
202        prunable: bool,
203        is_main: bool,
204    ) -> Worktree {
205        Worktree {
206            repo_name: "app".into(),
207            repo_path: PathBuf::from("/tmp/app"),
208            worktree_path: PathBuf::from("/tmp/app/.wt/x"),
209            branch: branch.map(|b| b.into()),
210            last_commit: None,
211            merged,
212            dirty,
213            prunable,
214            is_main,
215        }
216    }
217
218    #[test]
219    fn verdict_covers_all_combinations() {
220        assert_eq!(
221            wt(Some("main"), true, false, false, true).verdict(),
222            Verdict::Home
223        );
224        assert_eq!(
225            wt(Some("x"), false, false, true, false).verdict(),
226            Verdict::Prunable
227        );
228        assert_eq!(
229            wt(Some("x"), false, true, false, false).verdict(),
230            Verdict::Active
231        );
232        assert_eq!(
233            wt(Some("x"), true, false, false, false).verdict(),
234            Verdict::Deletable
235        );
236        assert_eq!(
237            wt(Some("x"), false, false, false, false).verdict(),
238            Verdict::Cold
239        );
240        // detached clean -> active
241        assert_eq!(
242            wt(None, false, false, false, false).verdict(),
243            Verdict::Active
244        );
245        // is_main beats prunable/dirty
246        assert_eq!(
247            wt(Some("main"), false, true, true, true).verdict(),
248            Verdict::Home
249        );
250    }
251
252    #[test]
253    fn short_name_uses_basename() {
254        let w = wt(Some("x"), false, false, false, false);
255        assert_eq!(w.short_name(), "app/x");
256    }
257
258    #[test]
259    fn worktrees_classifies_deletable_cold_and_dirty() {
260        let tmp = tempfile::tempdir().unwrap();
261        let repo = tmp.path().join("app");
262        testutil::init_repo(&repo);
263
264        // deletable: new branch off main (merged), clean worktree
265        let del = tmp.path().join("wt-del");
266        testutil::add_worktree(&repo, &del, "feat/done");
267
268        // cold: branch with its own commit (unmerged), clean worktree
269        let cold = tmp.path().join("wt-cold");
270        testutil::add_worktree(&repo, &cold, "feat/cold");
271        std::fs::write(cold.join("c.txt"), "c").unwrap();
272        testutil::git(&cold, &["add", "."]);
273        testutil::git(&cold, &["commit", "-m", "wip cold"]);
274
275        // active (dirty): new branch off main with an uncommitted file
276        let dirty = tmp.path().join("wt-dirty");
277        testutil::add_worktree(&repo, &dirty, "feat/dirty");
278        std::fs::write(dirty.join("d.txt"), "d").unwrap();
279
280        let all = worktrees(&repo).unwrap();
281        let by_branch = |b: &str| {
282            all.iter()
283                .find(|w| w.branch.as_deref() == Some(b))
284                .unwrap_or_else(|| panic!("branch {b} missing"))
285        };
286        assert_eq!(by_branch("feat/done").verdict(), Verdict::Deletable);
287        assert_eq!(by_branch("feat/cold").verdict(), Verdict::Cold);
288        assert_eq!(by_branch("feat/dirty").verdict(), Verdict::Active);
289
290        // main becomes home
291        let main = all.iter().find(|w| w.is_main).expect("main worktree");
292        assert_eq!(main.verdict(), Verdict::Home);
293    }
294
295    #[test]
296    fn scan_worktrees_aggregates_and_does_not_abort() {
297        let tmp = tempfile::tempdir().unwrap();
298        let repo = tmp.path().join("app");
299        testutil::init_repo(&repo);
300        let extra = tmp.path().join("wt-extra");
301        testutil::add_worktree(&repo, &extra, "feat/extra");
302
303        let (all, warnings) = scan_worktrees(&[tmp.path().to_path_buf()], 4);
304        assert!(all
305            .iter()
306            .any(|w| w.branch.as_deref() == Some("feat/extra")));
307        assert!(warnings.is_empty());
308    }
309}