1use 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#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Verdict {
11 Home,
13 Prunable,
15 Active,
17 Deletable,
19 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#[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 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, Some(_) if self.merged => Verdict::Deletable,
64 Some(_) => Verdict::Cold,
65 }
66 }
67
68 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
79pub 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 .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
147pub 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 assert_eq!(
227 wt(None, false, false, false, false).verdict(),
228 Verdict::Active
229 );
230 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 let del = tmp.path().join("wt-del");
251 testutil::add_worktree(&repo, &del, "feat/done");
252
253 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 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 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}