1use std::path::Path;
4
5use console::style;
6
7use crate::console as cwconsole;
8use crate::constants::{
9 format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
10 CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
11};
12use crate::error::Result;
13use crate::git;
14
15const MIN_TABLE_WIDTH: usize = 100;
17
18pub fn get_worktree_status(path: &Path, repo: &Path, branch: Option<&str>) -> String {
29 if !path.exists() {
30 return "stale".to_string();
31 }
32
33 if !crate::operations::busy::detect_busy(path).is_empty() {
37 return "busy".to_string();
38 }
39
40 if let Ok(cwd) = std::env::current_dir() {
43 let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
44 let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
45 if cwd_canon.starts_with(&path_canon) {
46 return "active".to_string();
47 }
48 }
49
50 if let Some(branch_name) = branch {
52 let base_branch = {
53 let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
54 git::get_config(&key, Some(repo))
55 .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
56 };
57
58 if let Some(pr_state) = git::get_pr_state(branch_name, Some(repo)) {
61 match pr_state.as_str() {
62 "MERGED" => return "merged".to_string(),
63 "OPEN" => return "pr-open".to_string(),
64 _ => {} }
66 }
67
68 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
71 return "merged".to_string();
72 }
73 }
74
75 if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
77 if result.returncode == 0 && !result.stdout.trim().is_empty() {
78 return "modified".to_string();
79 }
80 }
81
82 "clean".to_string()
83}
84
85pub fn format_age(age_days: f64) -> String {
87 if age_days < 1.0 {
88 let hours = (age_days * 24.0) as i64;
89 if hours > 0 {
90 format!("{}h ago", hours)
91 } else {
92 "just now".to_string()
93 }
94 } else if age_days < 7.0 {
95 format!("{}d ago", age_days as i64)
96 } else if age_days < 30.0 {
97 format!("{}w ago", (age_days / 7.0) as i64)
98 } else if age_days < 365.0 {
99 format!("{}mo ago", (age_days / 30.0) as i64)
100 } else {
101 format!("{}y ago", (age_days / 365.0) as i64)
102 }
103}
104
105fn path_age_str(path: &Path) -> String {
107 if !path.exists() {
108 return String::new();
109 }
110 path_age_days(path).map(format_age).unwrap_or_default()
111}
112
113struct WorktreeRow {
115 worktree_id: String,
116 current_branch: String,
117 status: String,
118 age: String,
119 rel_path: String,
120}
121
122pub fn list_worktrees() -> Result<()> {
124 let repo = git::get_repo_root(None)?;
125 let worktrees = git::parse_worktrees(&repo)?;
126
127 println!(
128 "\n{} {}\n",
129 style("Worktrees for repository:").cyan().bold(),
130 repo.display()
131 );
132
133 let mut rows: Vec<WorktreeRow> = Vec::new();
134
135 for (branch, path) in &worktrees {
136 let current_branch = git::normalize_branch_name(branch).to_string();
137 let status = get_worktree_status(path, &repo, Some(¤t_branch));
138 let rel_path = pathdiff::diff_paths(path, &repo)
139 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
140 .unwrap_or_else(|| path.to_string_lossy().to_string());
141 let age = path_age_str(path);
142
143 let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
145 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
146
147 rows.push(WorktreeRow {
148 worktree_id,
149 current_branch,
150 status,
151 age,
152 rel_path,
153 });
154 }
155
156 let term_width = cwconsole::terminal_width();
157 if term_width >= MIN_TABLE_WIDTH {
158 print_worktree_table(&rows);
159 } else {
160 print_worktree_compact(&rows);
161 }
162
163 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
165 if feature_count > 0 {
166 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
167 for row in &rows {
168 *counts.entry(row.status.as_str()).or_insert(0) += 1;
169 }
170
171 let mut summary_parts = Vec::new();
172 for &status_name in &[
173 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
174 ] {
175 if let Some(&count) = counts.get(status_name) {
176 if count > 0 {
177 let styled = cwconsole::status_style(status_name)
178 .apply_to(format!("{} {}", count, status_name));
179 summary_parts.push(styled.to_string());
180 }
181 }
182 }
183
184 let summary = if summary_parts.is_empty() {
185 format!("\n{} feature worktree(s)", feature_count)
186 } else {
187 format!(
188 "\n{} feature worktree(s) — {}",
189 feature_count,
190 summary_parts.join(", ")
191 )
192 };
193 println!("{}", summary);
194 }
195
196 println!();
197 Ok(())
198}
199
200fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
202 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
204 if let Some(intended) = git::get_config(&key, Some(repo)) {
205 return Some(intended);
206 }
207
208 let result = git::git_command(
210 &[
211 "config",
212 "--local",
213 "--get-regexp",
214 r"^worktree\..*\.intendedBranch",
215 ],
216 Some(repo),
217 false,
218 true,
219 )
220 .ok()?;
221
222 if result.returncode != 0 {
223 return None;
224 }
225
226 let repo_name = repo.file_name()?.to_string_lossy().to_string();
227
228 for line in result.stdout.trim().lines() {
229 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
230 if parts.len() == 2 {
231 let key_parts: Vec<&str> = parts[0].split('.').collect();
232 if key_parts.len() >= 2 {
233 let branch_from_key = key_parts[1];
234 let expected_path_name =
235 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
236 if let Some(name) = path.file_name() {
237 if name.to_string_lossy() == expected_path_name {
238 return Some(parts[1].to_string());
239 }
240 }
241 }
242 }
243 }
244
245 None
246}
247
248fn print_worktree_table(rows: &[WorktreeRow]) {
249 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
250 let max_br = rows
251 .iter()
252 .map(|r| r.current_branch.len())
253 .max()
254 .unwrap_or(20);
255 let wt_col = max_wt.clamp(12, 35) + 2;
256 let br_col = max_br.clamp(12, 35) + 2;
257
258 println!(
259 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
260 style(" ").dim(),
261 style("WORKTREE").dim(),
262 style("BRANCH").dim(),
263 style("STATUS").dim(),
264 style("AGE").dim(),
265 style("PATH").dim(),
266 wt_col = wt_col,
267 br_col = br_col,
268 );
269 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
270 println!(" {}", style("─".repeat(line_width)).dim());
271
272 for row in rows {
273 let icon = cwconsole::status_icon(&row.status);
274 let st = cwconsole::status_style(&row.status);
275
276 let branch_display = if row.worktree_id != row.current_branch {
277 style(format!("{} ⚠", row.current_branch))
278 .yellow()
279 .to_string()
280 } else {
281 row.current_branch.clone()
282 };
283
284 let status_styled = st.apply_to(format!("{:<10}", row.status));
285
286 println!(
287 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
288 st.apply_to(icon),
289 style(&row.worktree_id).bold(),
290 branch_display,
291 status_styled,
292 style(&row.age).dim(),
293 style(&row.rel_path).dim(),
294 wt_col = wt_col,
295 br_col = br_col,
296 );
297 }
298}
299
300fn print_worktree_compact(rows: &[WorktreeRow]) {
301 for row in rows {
302 let icon = cwconsole::status_icon(&row.status);
303 let st = cwconsole::status_style(&row.status);
304 let age_part = if row.age.is_empty() {
305 String::new()
306 } else {
307 format!(" {}", style(&row.age).dim())
308 };
309
310 println!(
311 " {} {} {}{}",
312 st.apply_to(icon),
313 style(&row.worktree_id).bold(),
314 st.apply_to(&row.status),
315 age_part,
316 );
317
318 let mut details = Vec::new();
319 if row.worktree_id != row.current_branch {
320 details.push(format!(
321 "branch: {}",
322 style(format!("{} ⚠", row.current_branch)).yellow()
323 ));
324 }
325 if !row.rel_path.is_empty() {
326 details.push(format!("{}", style(&row.rel_path).dim()));
327 }
328 if !details.is_empty() {
329 println!(" {}", details.join(" "));
330 }
331 }
332}
333
334pub fn show_status() -> Result<()> {
336 let repo = git::get_repo_root(None)?;
337
338 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
339 Ok(branch) => {
340 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
341 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
342 let base = git::get_config(&base_key, Some(&repo));
343 let base_path = git::get_config(&path_key, Some(&repo));
344
345 println!("\n{}", style("Current worktree:").cyan().bold());
346 println!(" Feature: {}", style(&branch).green());
347 println!(
348 " Base: {}",
349 style(base.as_deref().unwrap_or("N/A")).green()
350 );
351 println!(
352 " Base path: {}\n",
353 style(base_path.as_deref().unwrap_or("N/A")).blue()
354 );
355 }
356 Err(_) => {
357 println!(
358 "\n{}\n",
359 style("Current directory is not a feature worktree or is the main repository.")
360 .yellow()
361 );
362 }
363 }
364
365 list_worktrees()
366}
367
368pub fn show_tree() -> Result<()> {
370 let repo = git::get_repo_root(None)?;
371 let cwd = std::env::current_dir().unwrap_or_default();
372
373 let repo_name = repo
374 .file_name()
375 .map(|n| n.to_string_lossy().to_string())
376 .unwrap_or_else(|| "repo".to_string());
377
378 println!(
379 "\n{} (base repository)",
380 style(format!("{}/", repo_name)).cyan().bold()
381 );
382 println!("{}\n", style(repo.display().to_string()).dim());
383
384 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
385
386 if feature_worktrees.is_empty() {
387 println!("{}\n", style(" (no feature worktrees)").dim());
388 return Ok(());
389 }
390
391 let mut sorted = feature_worktrees;
392 sorted.sort_by(|a, b| a.0.cmp(&b.0));
393
394 for (i, (branch_name, path)) in sorted.iter().enumerate() {
395 let is_last = i == sorted.len() - 1;
396 let prefix = if is_last { "└── " } else { "├── " };
397
398 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
399 let is_current = cwd
400 .to_string_lossy()
401 .starts_with(&path.to_string_lossy().to_string());
402
403 let icon = cwconsole::status_icon(&status);
404 let st = cwconsole::status_style(&status);
405
406 let branch_display = if is_current {
407 st.clone()
408 .bold()
409 .apply_to(format!("★ {}", branch_name))
410 .to_string()
411 } else {
412 st.clone().apply_to(branch_name.as_str()).to_string()
413 };
414
415 let age = path_age_str(path);
416 let age_display = if age.is_empty() {
417 String::new()
418 } else {
419 format!(" {}", style(age).dim())
420 };
421
422 println!(
423 "{}{} {}{}",
424 prefix,
425 st.apply_to(icon),
426 branch_display,
427 age_display
428 );
429
430 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
431 format!("../{}", rel.display())
432 } else {
433 path.display().to_string()
434 };
435
436 let continuation = if is_last { " " } else { "│ " };
437 println!("{}{}", continuation, style(&path_display).dim());
438 }
439
440 println!("\n{}", style("Legend:").bold());
442 println!(
443 " {} active (current)",
444 cwconsole::status_style("active").apply_to("●")
445 );
446 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
447 println!(
448 " {} modified",
449 cwconsole::status_style("modified").apply_to("◉")
450 );
451 println!(
452 " {} pr-open",
453 cwconsole::status_style("pr-open").apply_to("⬆")
454 );
455 println!(
456 " {} merged",
457 cwconsole::status_style("merged").apply_to("✓")
458 );
459 println!(
460 " {} busy (other session)",
461 cwconsole::status_style("busy").apply_to("🔒")
462 );
463 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
464 println!(
465 " {} currently active worktree\n",
466 style("★").green().bold()
467 );
468
469 Ok(())
470}
471
472pub fn show_stats() -> Result<()> {
474 let repo = git::get_repo_root(None)?;
475 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
476
477 if feature_worktrees.is_empty() {
478 println!("\n{}\n", style("No feature worktrees found").yellow());
479 return Ok(());
480 }
481
482 println!();
483 println!(" {}", style("Worktree Statistics").cyan().bold());
484 println!(" {}", style("─".repeat(40)).dim());
485 println!();
486
487 struct WtData {
488 branch: String,
489 status: String,
490 age_days: f64,
491 commit_count: usize,
492 }
493
494 let mut data: Vec<WtData> = Vec::new();
495
496 for (branch_name, path) in &feature_worktrees {
497 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
498 let age_days = path_age_days(path).unwrap_or(0.0);
499
500 let commit_count = git::git_command(
501 &["rev-list", "--count", branch_name],
502 Some(path),
503 false,
504 true,
505 )
506 .ok()
507 .and_then(|r| {
508 if r.returncode == 0 {
509 r.stdout.trim().parse::<usize>().ok()
510 } else {
511 None
512 }
513 })
514 .unwrap_or(0);
515
516 data.push(WtData {
517 branch: branch_name.clone(),
518 status,
519 age_days,
520 commit_count,
521 });
522 }
523
524 let mut status_counts: std::collections::HashMap<&str, usize> =
526 std::collections::HashMap::new();
527 for d in &data {
528 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
529 }
530
531 println!(" {} {}", style("Total:").bold(), data.len());
532
533 let total = data.len();
535 let bar_width = 30;
536 let clean = *status_counts.get("clean").unwrap_or(&0);
537 let modified = *status_counts.get("modified").unwrap_or(&0);
538 let active = *status_counts.get("active").unwrap_or(&0);
539 let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
540 let merged = *status_counts.get("merged").unwrap_or(&0);
541 let busy = *status_counts.get("busy").unwrap_or(&0);
542 let stale = *status_counts.get("stale").unwrap_or(&0);
543
544 let bar_clean = (clean * bar_width) / total.max(1);
545 let bar_modified = (modified * bar_width) / total.max(1);
546 let bar_active = (active * bar_width) / total.max(1);
547 let bar_pr_open = (pr_open * bar_width) / total.max(1);
548 let bar_merged = (merged * bar_width) / total.max(1);
549 let bar_busy = (busy * bar_width) / total.max(1);
550 let bar_stale = (stale * bar_width) / total.max(1);
551 let bar_remainder = bar_width
553 - bar_clean
554 - bar_modified
555 - bar_active
556 - bar_pr_open
557 - bar_merged
558 - bar_busy
559 - bar_stale;
560
561 print!(" ");
562 print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
563 print!("{}", style("█".repeat(bar_modified)).yellow());
564 print!("{}", style("█".repeat(bar_active)).green().bold());
565 print!("{}", style("█".repeat(bar_pr_open)).cyan());
566 print!("{}", style("█".repeat(bar_merged)).magenta());
567 print!("{}", style("█".repeat(bar_busy)).red().bold());
568 print!("{}", style("█".repeat(bar_stale)).red());
569 println!();
570
571 let mut parts = Vec::new();
572 if clean > 0 {
573 parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
574 }
575 if modified > 0 {
576 parts.push(format!(
577 "{}",
578 style(format!("◉ {} modified", modified)).yellow()
579 ));
580 }
581 if active > 0 {
582 parts.push(format!(
583 "{}",
584 style(format!("● {} active", active)).green().bold()
585 ));
586 }
587 if pr_open > 0 {
588 parts.push(format!(
589 "{}",
590 style(format!("⬆ {} pr-open", pr_open)).cyan()
591 ));
592 }
593 if merged > 0 {
594 parts.push(format!(
595 "{}",
596 style(format!("✓ {} merged", merged)).magenta()
597 ));
598 }
599 if busy > 0 {
600 parts.push(format!(
601 "{}",
602 style(format!("🔒 {} busy", busy)).red().bold()
603 ));
604 }
605 if stale > 0 {
606 parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
607 }
608 println!(" {}", parts.join(" "));
609 println!();
610
611 let ages: Vec<f64> = data
613 .iter()
614 .filter(|d| d.age_days > 0.0)
615 .map(|d| d.age_days)
616 .collect();
617 if !ages.is_empty() {
618 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
619 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
620 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
621
622 println!(" {} Age", style("◷").dim());
623 println!(
624 " avg {} oldest {} newest {}",
625 style(format!("{:.1}d", avg)).bold(),
626 style(format!("{:.1}d", oldest)).yellow(),
627 style(format!("{:.1}d", newest)).green(),
628 );
629 println!();
630 }
631
632 let commits: Vec<usize> = data
634 .iter()
635 .filter(|d| d.commit_count > 0)
636 .map(|d| d.commit_count)
637 .collect();
638 if !commits.is_empty() {
639 let total: usize = commits.iter().sum();
640 let avg = total as f64 / commits.len() as f64;
641 let max_c = *commits.iter().max().unwrap_or(&0);
642
643 println!(" {} Commits", style("⟲").dim());
644 println!(
645 " total {} avg {:.1} max {}",
646 style(total).bold(),
647 avg,
648 style(max_c).bold(),
649 );
650 println!();
651 }
652
653 println!(" {}", style("Oldest Worktrees").bold());
655 let mut by_age = data.iter().collect::<Vec<_>>();
656 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
657 let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
658 for d in by_age.iter().take(5) {
659 if d.age_days > 0.0 {
660 let icon = cwconsole::status_icon(&d.status);
661 let st = cwconsole::status_style(&d.status);
662 let bar_len = ((d.age_days / max_age) * 15.0) as usize;
663 println!(
664 " {} {:<25} {} {}",
665 st.apply_to(icon),
666 d.branch,
667 style("▓".repeat(bar_len.max(1))).dim(),
668 style(format_age(d.age_days)).dim(),
669 );
670 }
671 }
672 println!();
673
674 println!(" {}", style("Most Active (by commits)").bold());
676 let mut by_commits = data.iter().collect::<Vec<_>>();
677 by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
678 let max_commits = by_commits
679 .first()
680 .map(|d| d.commit_count)
681 .unwrap_or(1)
682 .max(1);
683 for d in by_commits.iter().take(5) {
684 if d.commit_count > 0 {
685 let icon = cwconsole::status_icon(&d.status);
686 let st = cwconsole::status_style(&d.status);
687 let bar_len = (d.commit_count * 15) / max_commits;
688 println!(
689 " {} {:<25} {} {}",
690 st.apply_to(icon),
691 d.branch,
692 style("▓".repeat(bar_len.max(1))).cyan(),
693 style(format!("{} commits", d.commit_count)).dim(),
694 );
695 }
696 }
697 println!();
698
699 Ok(())
700}
701
702pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
704 let repo = git::get_repo_root(None)?;
705
706 if !git::branch_exists(branch1, Some(&repo)) {
707 return Err(crate::error::CwError::InvalidBranch(format!(
708 "Branch '{}' not found",
709 branch1
710 )));
711 }
712 if !git::branch_exists(branch2, Some(&repo)) {
713 return Err(crate::error::CwError::InvalidBranch(format!(
714 "Branch '{}' not found",
715 branch2
716 )));
717 }
718
719 println!("\n{}", style("Comparing branches:").cyan().bold());
720 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
721
722 if files {
723 let result = git::git_command(
724 &["diff", "--name-status", branch1, branch2],
725 Some(&repo),
726 true,
727 true,
728 )?;
729 println!("{}\n", style("Changed files:").bold());
730 if result.stdout.trim().is_empty() {
731 println!(" {}", style("No differences found").dim());
732 } else {
733 for line in result.stdout.trim().lines() {
734 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
735 if parts.len() == 2 {
736 let (status_char, filename) = (parts[0], parts[1]);
737 let c = status_char.chars().next().unwrap_or('?');
738 let status_name = match c {
739 'M' => "Modified",
740 'A' => "Added",
741 'D' => "Deleted",
742 'R' => "Renamed",
743 'C' => "Copied",
744 _ => "Changed",
745 };
746 let styled_status = match c {
747 'M' => style(status_char).yellow(),
748 'A' => style(status_char).green(),
749 'D' => style(status_char).red(),
750 'R' | 'C' => style(status_char).cyan(),
751 _ => style(status_char),
752 };
753 println!(" {} {} ({})", styled_status, filename, status_name);
754 }
755 }
756 }
757 } else if summary {
758 let result = git::git_command(
759 &["diff", "--stat", branch1, branch2],
760 Some(&repo),
761 true,
762 true,
763 )?;
764 println!("{}\n", style("Diff summary:").bold());
765 if result.stdout.trim().is_empty() {
766 println!(" {}", style("No differences found").dim());
767 } else {
768 println!("{}", result.stdout);
769 }
770 } else {
771 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
772 if result.stdout.trim().is_empty() {
773 println!("{}\n", style("No differences found").dim());
774 } else {
775 println!("{}", result.stdout);
776 }
777 }
778
779 Ok(())
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn test_format_age_just_now() {
788 assert_eq!(format_age(0.0), "just now");
789 assert_eq!(format_age(0.001), "just now"); }
791
792 #[test]
793 fn test_format_age_hours() {
794 assert_eq!(format_age(1.0 / 24.0), "1h ago"); assert_eq!(format_age(0.5), "12h ago"); assert_eq!(format_age(0.99), "23h ago"); }
798
799 #[test]
800 fn test_format_age_days() {
801 assert_eq!(format_age(1.0), "1d ago");
802 assert_eq!(format_age(1.5), "1d ago");
803 assert_eq!(format_age(6.9), "6d ago");
804 }
805
806 #[test]
807 fn test_format_age_weeks() {
808 assert_eq!(format_age(7.0), "1w ago");
809 assert_eq!(format_age(14.0), "2w ago");
810 assert_eq!(format_age(29.0), "4w ago");
811 }
812
813 #[test]
814 fn test_format_age_months() {
815 assert_eq!(format_age(30.0), "1mo ago");
816 assert_eq!(format_age(60.0), "2mo ago");
817 assert_eq!(format_age(364.0), "12mo ago");
818 }
819
820 #[test]
821 fn test_format_age_years() {
822 assert_eq!(format_age(365.0), "1y ago");
823 assert_eq!(format_age(730.0), "2y ago");
824 }
825
826 #[test]
827 fn test_format_age_boundary_below_one_hour() {
828 assert_eq!(format_age(0.04), "just now"); }
831
832 #[test]
836 #[cfg(unix)]
837 fn test_get_worktree_status_busy_from_lockfile() {
838 use crate::operations::lockfile::LockEntry;
839 use std::fs;
840 use std::process::{Command, Stdio};
841
842 let tmp = tempfile::TempDir::new().unwrap();
843 let repo = tmp.path();
844 let wt = repo.join("wt1");
845 fs::create_dir_all(wt.join(".git")).unwrap();
846
847 let mut child = Command::new("sleep")
851 .arg("30")
852 .stdout(Stdio::null())
853 .stderr(Stdio::null())
854 .spawn()
855 .expect("spawn sleep");
856 let foreign_pid: u32 = child.id();
857
858 let entry = LockEntry {
859 version: crate::operations::lockfile::LOCK_VERSION,
860 pid: foreign_pid,
861 started_at: 0,
862 cmd: "claude".to_string(),
863 };
864 fs::write(
865 wt.join(".git").join("gw-session.lock"),
866 serde_json::to_string(&entry).unwrap(),
867 )
868 .unwrap();
869
870 let status = get_worktree_status(&wt, repo, Some("wt1"));
871
872 let _ = child.kill();
874 let _ = child.wait();
875
876 assert_eq!(status, "busy");
877 }
878
879 #[test]
880 fn test_get_worktree_status_stale() {
881 use std::path::PathBuf;
882 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
883 let repo = PathBuf::from("/tmp");
884 assert_eq!(get_worktree_status(&non_existent, &repo, None), "stale");
885 }
886}