1use crate::scanner::OpenLoop;
3use crate::worktrees::{Verdict, Worktree};
4use chrono::{DateTime, Utc};
5use std::collections::HashSet;
6use std::path::PathBuf;
7
8pub fn human_age(now: DateTime<Utc>, then: DateTime<Utc>) -> String {
14 let mins = (now - then).num_minutes().max(0);
15 if mins < 60 {
16 format!("{mins}min")
17 } else if mins < 48 * 60 {
18 format!("{}h", mins / 60)
19 } else {
20 format!("{}d", mins / (60 * 24))
21 }
22}
23
24pub fn fmt_count(v: Option<u32>) -> String {
25 v.map(|n| n.to_string()).unwrap_or_else(|| "-".into())
26}
27
28pub fn render_table(loops: &[OpenLoop], now: DateTime<Utc>) -> String {
32 if loops.is_empty() {
33 return "No open loops. All finished or ignored.\n".into();
34 }
35 let mut sorted: Vec<&OpenLoop> = loops.iter().collect();
36 sorted.sort_by_key(|l| l.last_commit);
37 let key_w = sorted
38 .iter()
39 .map(|l| l.key().len())
40 .max()
41 .unwrap_or(4)
42 .max(4);
43 let mut out = format!(
44 "{:<key_w$} {:>9} {:>5} {:>6}\n",
45 "LOOP", "IDLE", "AHEAD", "BEHIND"
46 );
47 for l in sorted {
48 out.push_str(&format!(
49 "{:<key_w$} {:>9} {:>5} {:>6}\n",
50 l.key(),
51 human_age(now, l.last_commit),
52 fmt_count(l.ahead),
53 fmt_count(l.behind)
54 ));
55 }
56 out
57}
58
59fn verdict_rank(v: &Verdict) -> u8 {
60 match v {
61 Verdict::Deletable | Verdict::Prunable => 0,
62 Verdict::Cold => 1,
63 Verdict::Active => 2,
64 Verdict::Home => 3,
65 }
66}
67
68fn branch_label(w: &Worktree) -> String {
69 w.branch.clone().unwrap_or_else(|| "(detached)".into())
70}
71
72pub fn render_worktrees(wts: &[Worktree], now: DateTime<Utc>) -> String {
76 if wts.is_empty() {
77 return "No worktrees found.\n".into();
78 }
79 let epoch = DateTime::from_timestamp(0, 0).unwrap();
80 let mut sorted: Vec<&Worktree> = wts.iter().collect();
81 sorted.sort_by_key(|w| (verdict_rank(&w.verdict()), w.last_commit.unwrap_or(epoch)));
82
83 let name_w = sorted
84 .iter()
85 .map(|w| w.short_name().len())
86 .max()
87 .unwrap_or(8)
88 .max(8);
89 let branch_w = sorted
90 .iter()
91 .map(|w| branch_label(w).len())
92 .max()
93 .unwrap_or(6)
94 .max(6);
95
96 let mut out = format!(
97 "{:<name_w$} {:<branch_w$} {:>5} {:>6} {:>5} {}\n",
98 "WORKTREE", "BRANCH", "IDLE", "MERGED", "STATE", "VERDICT"
99 );
100 for w in &sorted {
101 out.push_str(&format!(
102 "{:<name_w$} {:<branch_w$} {:>5} {:>6} {:>5} {}\n",
103 w.short_name(),
104 branch_label(w),
105 w.last_commit
106 .map(|t| human_age(now, t))
107 .unwrap_or_else(|| "?".into()),
108 if w.merged { "yes" } else { "no" },
109 if w.dirty { "dirty" } else { "clean" },
110 w.verdict().label()
111 ));
112 }
113
114 let mut cmds: Vec<String> = Vec::new();
115 let mut pruned: HashSet<PathBuf> = HashSet::new();
116 for w in &sorted {
117 match w.verdict() {
118 Verdict::Deletable => {
119 if let Some(b) = &w.branch {
120 cmds.push(format!(
121 "git -C {repo} worktree remove {wt} && git -C {repo} branch -d {b}",
122 repo = w.repo_path.display(),
123 wt = w.worktree_path.display(),
124 ));
125 }
126 }
127 Verdict::Prunable => {
128 if pruned.insert(w.repo_path.clone()) {
129 cmds.push(format!("git -C {} worktree prune", w.repo_path.display()));
130 }
131 }
132 _ => {}
133 }
134 }
135 if cmds.is_empty() {
136 out.push_str("\n# nothing to clean up.\n");
137 } else {
138 out.push_str(&format!(
139 "\n# {} worktree(s) to clean up. Copy to run:\n",
140 cmds.len()
141 ));
142 for c in &cmds {
143 out.push_str(c);
144 out.push('\n');
145 }
146 }
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::scanner::OpenLoop;
154 use crate::worktrees::Worktree;
155 use chrono::{Duration, Utc};
156 use std::path::PathBuf;
157
158 fn lp(branch: &str, idle_days: i64) -> OpenLoop {
159 OpenLoop {
160 root_label: "app".into(),
161 repo_name: "app".into(),
162 repo_path: PathBuf::from("/tmp/app"),
163 branch: branch.into(),
164 head_sha: "abc".into(),
165 last_commit: Utc::now() - Duration::days(idle_days),
166 ahead: Some(1),
167 behind: Some(0),
168 }
169 }
170
171 #[test]
172 fn human_age_minutes_hours_days() {
173 let now = Utc::now();
174 assert_eq!(human_age(now, now - Duration::minutes(5)), "5min");
175 assert_eq!(human_age(now, now - Duration::hours(3)), "3h");
176 assert_eq!(human_age(now, now - Duration::days(12)), "12d");
177 }
178
179 #[test]
180 fn render_table_sorts_most_idle_first() {
181 let t = render_table(&[lp("recente", 1), lp("antiga", 30)], Utc::now());
182 let pos_antiga = t.find("antiga").unwrap();
183 let pos_recente = t.find("recente").unwrap();
184 assert!(pos_antiga < pos_recente);
185 assert!(t.contains("LOOP"));
186 assert!(t.contains("30d"));
187 }
188
189 #[test]
190 fn render_table_shows_dash_for_none_ahead_behind() {
191 let mut l = lp("feat/x", 1);
192 l.ahead = None;
193 l.behind = None;
194 let t = render_table(&[l], Utc::now());
195 let line = t.lines().find(|ln| ln.contains("feat/x")).unwrap();
196 assert!(line.contains(" - "), "expected dashes in: {line}");
197 }
198
199 #[test]
200 fn render_table_empty_celebrates() {
201 assert!(render_table(&[], Utc::now()).contains("No open loops"));
202 }
203
204 fn wt(branch: &str, merged: bool, dirty: bool, idade_dias: i64) -> Worktree {
205 Worktree {
206 repo_name: "app".into(),
207 repo_path: std::path::PathBuf::from("/tmp/app"),
208 worktree_path: std::path::PathBuf::from(format!("/tmp/app/{branch}")),
209 branch: Some(branch.into()),
210 last_commit: Some(Utc::now() - Duration::days(idade_dias)),
211 merged,
212 dirty,
213 prunable: false,
214 is_main: false,
215 }
216 }
217
218 #[test]
219 fn render_worktrees_sorts_deletable_first_and_shows_command() {
220 let out = render_worktrees(
221 &[
222 wt("feat/cold", false, false, 40),
223 wt("fix/done", true, false, 8),
224 ],
225 Utc::now(),
226 );
227 assert!(out.contains("WORKTREE"));
229 assert!(out.contains("VERDICT"));
230 let pos_done = out.find("fix/done").unwrap();
232 let pos_cold = out.find("feat/cold").unwrap();
233 assert!(pos_done < pos_cold);
234 assert!(out.contains("worktree remove"));
236 assert!(out.contains("branch -d fix/done"));
237 assert!(out.is_ascii());
239 }
240
241 #[test]
242 fn render_worktrees_no_action_says_nothing() {
243 let out = render_worktrees(&[wt("feat/cold", false, false, 3)], Utc::now());
244 assert!(out.contains("nothing to clean up"));
245 }
246
247 #[test]
248 fn render_worktrees_empty() {
249 assert!(render_worktrees(&[], Utc::now()).contains("No worktrees found"));
250 }
251}