1use std::path::Path;
4use std::sync::mpsc;
5
6use console::style;
11use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
12
13use crate::console as cwconsole;
14use crate::constants::{
15 format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
16 CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
17};
18use crate::error::Result;
19use crate::git;
20
21use rayon::prelude::*;
22
23use super::pr_cache::PrCache;
24
25const MIN_TABLE_WIDTH: usize = 100;
27
28pub fn get_worktree_status(
45 path: &Path,
46 repo: &Path,
47 branch: Option<&str>,
48 pr_cache: &PrCache,
49) -> String {
50 if !path.exists() {
51 return "stale".to_string();
52 }
53
54 if !crate::operations::busy::detect_busy(path).is_empty() {
58 return "busy".to_string();
59 }
60
61 if let Ok(cwd) = std::env::current_dir() {
64 let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
65 let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
66 if cwd_canon.starts_with(&path_canon) {
67 return "active".to_string();
68 }
69 }
70
71 if let Some(branch_name) = branch {
73 let base_branch = {
74 let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
75 git::get_config(&key, Some(repo))
76 .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
77 };
78
79 if let Some(state) = pr_cache.state(branch_name) {
81 match state {
82 super::pr_cache::PrState::Merged => return "merged".to_string(),
83 super::pr_cache::PrState::Open => return "pr-open".to_string(),
84 _ => {}
86 }
87 }
88
89 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
92 return "merged".to_string();
93 }
94 }
95
96 if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
98 if result.returncode == 0 && !result.stdout.trim().is_empty() {
99 return "modified".to_string();
100 }
101 }
102
103 "clean".to_string()
104}
105
106pub fn format_age(age_days: f64) -> String {
108 if age_days < 1.0 {
109 let hours = (age_days * 24.0) as i64;
110 if hours > 0 {
111 format!("{}h ago", hours)
112 } else {
113 "just now".to_string()
114 }
115 } else if age_days < 7.0 {
116 format!("{}d ago", age_days as i64)
117 } else if age_days < 30.0 {
118 format!("{}w ago", (age_days / 7.0) as i64)
119 } else if age_days < 365.0 {
120 format!("{}mo ago", (age_days / 30.0) as i64)
121 } else {
122 format!("{}y ago", (age_days / 365.0) as i64)
123 }
124}
125
126fn path_age_str(path: &Path) -> String {
128 if !path.exists() {
129 return String::new();
130 }
131 path_age_days(path).map(format_age).unwrap_or_default()
132}
133
134struct WorktreeRow {
136 worktree_id: String,
137 current_branch: String,
138 status: String,
139 age: String,
140 rel_path: String,
141}
142
143#[derive(Clone)]
147struct RowInput {
148 path: std::path::PathBuf,
149 current_branch: String,
150 worktree_id: String,
151 age: String,
152 rel_path: String,
153}
154
155impl RowInput {
156 fn into_row(self, status: String) -> WorktreeRow {
157 WorktreeRow {
158 worktree_id: self.worktree_id,
159 current_branch: self.current_branch,
160 status,
161 age: self.age,
162 rel_path: self.rel_path,
163 }
164 }
165}
166
167pub fn list_worktrees(no_cache: bool) -> Result<()> {
169 let repo = git::get_repo_root(None)?;
170 let worktrees = git::parse_worktrees(&repo)?;
171
172 println!(
173 "\n{} {}\n",
174 style("Worktrees for repository:").cyan().bold(),
175 repo.display()
176 );
177
178 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
179
180 let inputs: Vec<RowInput> = worktrees
182 .iter()
183 .map(|(branch, path)| {
184 let current_branch = git::normalize_branch_name(branch).to_string();
185 let rel_path = pathdiff::diff_paths(path, &repo)
186 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
187 .unwrap_or_else(|| path.to_string_lossy().to_string());
188 let age = path_age_str(path);
189 let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
190 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
191 RowInput {
192 path: path.clone(),
193 current_branch,
194 worktree_id,
195 age,
196 rel_path,
197 }
198 })
199 .collect();
200
201 if inputs.is_empty() {
202 println!(" {}\n", style("No worktrees found.").dim());
203 return Ok(());
204 }
205
206 let is_tty = crate::tui::stdout_is_tty();
207 let term_width = cwconsole::terminal_width();
210 let narrow = term_width < MIN_TABLE_WIDTH;
213 let use_progressive = is_tty && !narrow;
214
215 let rows: Vec<WorktreeRow> = if use_progressive {
216 render_rows_progressive(&repo, &pr_cache, inputs)?
217 } else {
218 inputs
220 .into_par_iter()
221 .map(|i| {
222 let status =
223 get_worktree_status(&i.path, &repo, Some(&i.current_branch), &pr_cache);
224 i.into_row(status)
225 })
226 .collect()
227 };
228
229 if !use_progressive {
232 if narrow {
233 print_worktree_compact(&rows);
234 } else {
235 print_worktree_table(&rows);
236 }
237 }
238
239 print_summary_footer(&rows);
246
247 println!();
248 Ok(())
249}
250
251type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
258
259struct TerminalGuard(Option<CrosstermTerminal>);
266
267impl TerminalGuard {
268 fn new(terminal: CrosstermTerminal) -> Self {
269 Self(Some(terminal))
273 }
274
275 fn as_mut(&mut self) -> &mut CrosstermTerminal {
276 self.0.as_mut().expect("terminal already taken")
277 }
278}
279
280impl Drop for TerminalGuard {
281 fn drop(&mut self) {
282 let _ = self.0.take(); ratatui::restore();
284 crate::tui::mark_ratatui_inactive();
287 }
288}
289
290fn render_rows_progressive(
291 repo: &std::path::Path,
292 pr_cache: &PrCache,
293 inputs: Vec<RowInput>,
294) -> Result<Vec<WorktreeRow>> {
295 let row_data: Vec<crate::tui::list_view::RowData> = inputs
297 .iter()
298 .map(|i| crate::tui::list_view::RowData {
299 worktree_id: i.worktree_id.clone(),
300 current_branch: i.current_branch.clone(),
301 status: crate::tui::list_view::PLACEHOLDER.to_string(),
302 age: i.age.clone(),
303 rel_path: i.rel_path.clone(),
304 })
305 .collect();
306 let mut app = crate::tui::list_view::ListApp::new(row_data);
307
308 let viewport_height = u16::try_from(inputs.len())
311 .unwrap_or(u16::MAX)
312 .saturating_add(2)
313 .max(3);
314
315 let stdout = std::io::stdout();
316 let backend = CrosstermBackend::new(stdout);
317 crate::tui::mark_ratatui_active();
324 let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
325 Terminal::with_options(
326 backend,
327 TerminalOptions {
328 viewport: Viewport::Inline(viewport_height),
329 },
330 )
331 })) {
332 Ok(Ok(t)) => t,
333 Ok(Err(e)) => {
334 crate::tui::mark_ratatui_inactive();
335 return Err(e.into());
336 }
337 Err(panic) => {
338 crate::tui::mark_ratatui_inactive();
339 std::panic::resume_unwind(panic);
340 }
341 };
342 let mut guard = TerminalGuard::new(terminal);
343 let (tx, rx) = mpsc::channel();
358
359 guard.as_mut().draw(|f| app.render(f))?;
364
365 std::thread::scope(|s| -> Result<()> {
370 let producer = s.spawn(move || {
371 inputs
372 .par_iter()
373 .enumerate()
374 .for_each_with(tx, |tx, (i, input)| {
375 let status = get_worktree_status(
376 &input.path,
377 repo,
378 Some(&input.current_branch),
379 pr_cache,
380 );
381 let _ = tx.send((i, status));
382 });
383 });
384
385 let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
386 let producer_result = producer.join();
387 if let Err(panic) = producer_result {
388 let msg = panic
390 .downcast_ref::<&str>()
391 .map(|s| (*s).to_string())
392 .or_else(|| panic.downcast_ref::<String>().cloned())
393 .unwrap_or_else(|| "non-string panic payload".to_string());
394 eprintln!(
395 "warning: status producer thread panicked, some rows may show \"unknown\": {}",
396 msg
397 );
398 }
399 run_result.map_err(crate::error::CwError::from)
400 })?;
401
402 if app.finalize_pending("unknown") {
409 guard.as_mut().draw(|f| app.render(f))?;
410 }
411
412 Ok(app.into_rows().into_iter().map(Into::into).collect())
413}
414
415impl From<crate::tui::list_view::RowData> for WorktreeRow {
419 fn from(r: crate::tui::list_view::RowData) -> Self {
420 let crate::tui::list_view::RowData {
421 worktree_id,
422 current_branch,
423 status,
424 age,
425 rel_path,
426 } = r;
427 WorktreeRow {
428 worktree_id,
429 current_branch,
430 status,
431 age,
432 rel_path,
433 }
434 }
435}
436
437fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
439 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
441 if let Some(intended) = git::get_config(&key, Some(repo)) {
442 return Some(intended);
443 }
444
445 let result = git::git_command(
447 &[
448 "config",
449 "--local",
450 "--get-regexp",
451 r"^worktree\..*\.intendedBranch",
452 ],
453 Some(repo),
454 false,
455 true,
456 )
457 .ok()?;
458
459 if result.returncode != 0 {
460 return None;
461 }
462
463 let repo_name = repo.file_name()?.to_string_lossy().to_string();
464
465 for line in result.stdout.trim().lines() {
466 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
467 if parts.len() == 2 {
468 let key_parts: Vec<&str> = parts[0].split('.').collect();
469 if key_parts.len() >= 2 {
470 let branch_from_key = key_parts[1];
471 let expected_path_name =
472 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
473 if let Some(name) = path.file_name() {
474 if name.to_string_lossy() == expected_path_name {
475 return Some(parts[1].to_string());
476 }
477 }
478 }
479 }
480 }
481
482 None
483}
484
485fn print_summary_footer(rows: &[WorktreeRow]) {
486 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
489 if feature_count == 0 {
490 return;
491 }
492
493 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
494 for row in rows {
495 *counts.entry(row.status.as_str()).or_insert(0) += 1;
496 }
497
498 let mut summary_parts = Vec::new();
499 for &status_name in &[
500 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
501 ] {
502 if let Some(&count) = counts.get(status_name) {
503 if count > 0 {
504 let styled = cwconsole::status_style(status_name)
505 .apply_to(format!("{} {}", count, status_name));
506 summary_parts.push(styled.to_string());
507 }
508 }
509 }
510
511 let summary = if summary_parts.is_empty() {
512 format!("\n{} feature worktree(s)", feature_count)
513 } else {
514 format!(
515 "\n{} feature worktree(s) — {}",
516 feature_count,
517 summary_parts.join(", ")
518 )
519 };
520 println!("{}", summary);
521}
522
523fn print_worktree_table(rows: &[WorktreeRow]) {
524 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
525 let max_br = rows
526 .iter()
527 .map(|r| r.current_branch.len())
528 .max()
529 .unwrap_or(20);
530 let wt_col = max_wt.clamp(12, 35) + 2;
531 let br_col = max_br.clamp(12, 35) + 2;
532
533 println!(
534 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
535 style(" ").dim(),
536 style("WORKTREE").dim(),
537 style("BRANCH").dim(),
538 style("STATUS").dim(),
539 style("AGE").dim(),
540 style("PATH").dim(),
541 wt_col = wt_col,
542 br_col = br_col,
543 );
544 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
545 println!(" {}", style("─".repeat(line_width)).dim());
546
547 for row in rows {
548 let icon = cwconsole::status_icon(&row.status);
549 let st = cwconsole::status_style(&row.status);
550
551 let branch_display = if row.worktree_id != row.current_branch {
552 style(format!("{} ⚠", row.current_branch))
553 .yellow()
554 .to_string()
555 } else {
556 row.current_branch.clone()
557 };
558
559 let status_styled = st.apply_to(format!("{:<10}", row.status));
560
561 println!(
562 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
563 st.apply_to(icon),
564 style(&row.worktree_id).bold(),
565 branch_display,
566 status_styled,
567 style(&row.age).dim(),
568 style(&row.rel_path).dim(),
569 wt_col = wt_col,
570 br_col = br_col,
571 );
572 }
573}
574
575fn print_worktree_compact(rows: &[WorktreeRow]) {
576 for row in rows {
577 let icon = cwconsole::status_icon(&row.status);
578 let st = cwconsole::status_style(&row.status);
579 let age_part = if row.age.is_empty() {
580 String::new()
581 } else {
582 format!(" {}", style(&row.age).dim())
583 };
584
585 println!(
586 " {} {} {}{}",
587 st.apply_to(icon),
588 style(&row.worktree_id).bold(),
589 st.apply_to(&row.status),
590 age_part,
591 );
592
593 let mut details = Vec::new();
594 if row.worktree_id != row.current_branch {
595 details.push(format!(
596 "branch: {}",
597 style(format!("{} ⚠", row.current_branch)).yellow()
598 ));
599 }
600 if !row.rel_path.is_empty() {
601 details.push(format!("{}", style(&row.rel_path).dim()));
602 }
603 if !details.is_empty() {
604 println!(" {}", details.join(" "));
605 }
606 }
607}
608
609pub fn show_status(no_cache: bool) -> Result<()> {
611 let repo = git::get_repo_root(None)?;
612
613 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
614 Ok(branch) => {
615 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
616 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
617 let base = git::get_config(&base_key, Some(&repo));
618 let base_path = git::get_config(&path_key, Some(&repo));
619
620 println!("\n{}", style("Current worktree:").cyan().bold());
621 println!(" Feature: {}", style(&branch).green());
622 println!(
623 " Base: {}",
624 style(base.as_deref().unwrap_or("N/A")).green()
625 );
626 println!(
627 " Base path: {}\n",
628 style(base_path.as_deref().unwrap_or("N/A")).blue()
629 );
630 }
631 Err(_) => {
632 println!(
633 "\n{}\n",
634 style("Current directory is not a feature worktree or is the main repository.")
635 .yellow()
636 );
637 }
638 }
639
640 list_worktrees(no_cache)
641}
642
643pub fn show_tree(no_cache: bool) -> Result<()> {
645 let repo = git::get_repo_root(None)?;
646 let cwd = std::env::current_dir().unwrap_or_default();
647
648 let repo_name = repo
649 .file_name()
650 .map(|n| n.to_string_lossy().to_string())
651 .unwrap_or_else(|| "repo".to_string());
652
653 println!(
654 "\n{} (base repository)",
655 style(format!("{}/", repo_name)).cyan().bold()
656 );
657 println!("{}\n", style(repo.display().to_string()).dim());
658
659 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
660
661 if feature_worktrees.is_empty() {
662 println!("{}\n", style(" (no feature worktrees)").dim());
663 return Ok(());
664 }
665
666 let mut sorted = feature_worktrees;
667 sorted.sort_by(|a, b| a.0.cmp(&b.0));
668
669 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
670
671 for (i, (branch_name, path)) in sorted.iter().enumerate() {
672 let is_last = i == sorted.len() - 1;
673 let prefix = if is_last { "└── " } else { "├── " };
674
675 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
676 let is_current = cwd
677 .to_string_lossy()
678 .starts_with(&path.to_string_lossy().to_string());
679
680 let icon = cwconsole::status_icon(&status);
681 let st = cwconsole::status_style(&status);
682
683 let branch_display = if is_current {
684 st.clone()
685 .bold()
686 .apply_to(format!("★ {}", branch_name))
687 .to_string()
688 } else {
689 st.clone().apply_to(branch_name.as_str()).to_string()
690 };
691
692 let age = path_age_str(path);
693 let age_display = if age.is_empty() {
694 String::new()
695 } else {
696 format!(" {}", style(age).dim())
697 };
698
699 println!(
700 "{}{} {}{}",
701 prefix,
702 st.apply_to(icon),
703 branch_display,
704 age_display
705 );
706
707 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
708 format!("../{}", rel.display())
709 } else {
710 path.display().to_string()
711 };
712
713 let continuation = if is_last { " " } else { "│ " };
714 println!("{}{}", continuation, style(&path_display).dim());
715 }
716
717 println!("\n{}", style("Legend:").bold());
719 println!(
720 " {} active (current)",
721 cwconsole::status_style("active").apply_to("●")
722 );
723 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
724 println!(
725 " {} modified",
726 cwconsole::status_style("modified").apply_to("◉")
727 );
728 println!(
729 " {} pr-open",
730 cwconsole::status_style("pr-open").apply_to("⬆")
731 );
732 println!(
733 " {} merged",
734 cwconsole::status_style("merged").apply_to("✓")
735 );
736 println!(
737 " {} busy (other session)",
738 cwconsole::status_style("busy").apply_to("🔒")
739 );
740 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
741 println!(
742 " {} currently active worktree\n",
743 style("★").green().bold()
744 );
745
746 Ok(())
747}
748
749pub fn show_stats(no_cache: bool) -> Result<()> {
751 let repo = git::get_repo_root(None)?;
752 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
753
754 if feature_worktrees.is_empty() {
755 println!("\n{}\n", style("No feature worktrees found").yellow());
756 return Ok(());
757 }
758
759 println!();
760 println!(" {}", style("Worktree Statistics").cyan().bold());
761 println!(" {}", style("─".repeat(40)).dim());
762 println!();
763
764 struct WtData {
765 branch: String,
766 status: String,
767 age_days: f64,
768 commit_count: usize,
769 }
770
771 let mut data: Vec<WtData> = Vec::new();
772
773 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
774
775 for (branch_name, path) in &feature_worktrees {
776 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
777 let age_days = path_age_days(path).unwrap_or(0.0);
778
779 let commit_count = git::git_command(
780 &["rev-list", "--count", branch_name],
781 Some(path),
782 false,
783 true,
784 )
785 .ok()
786 .and_then(|r| {
787 if r.returncode == 0 {
788 r.stdout.trim().parse::<usize>().ok()
789 } else {
790 None
791 }
792 })
793 .unwrap_or(0);
794
795 data.push(WtData {
796 branch: branch_name.clone(),
797 status,
798 age_days,
799 commit_count,
800 });
801 }
802
803 let mut status_counts: std::collections::HashMap<&str, usize> =
805 std::collections::HashMap::new();
806 for d in &data {
807 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
808 }
809
810 println!(" {} {}", style("Total:").bold(), data.len());
811
812 let total = data.len();
814 let bar_width = 30;
815 let clean = *status_counts.get("clean").unwrap_or(&0);
816 let modified = *status_counts.get("modified").unwrap_or(&0);
817 let active = *status_counts.get("active").unwrap_or(&0);
818 let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
819 let merged = *status_counts.get("merged").unwrap_or(&0);
820 let busy = *status_counts.get("busy").unwrap_or(&0);
821 let stale = *status_counts.get("stale").unwrap_or(&0);
822
823 let bar_clean = (clean * bar_width) / total.max(1);
824 let bar_modified = (modified * bar_width) / total.max(1);
825 let bar_active = (active * bar_width) / total.max(1);
826 let bar_pr_open = (pr_open * bar_width) / total.max(1);
827 let bar_merged = (merged * bar_width) / total.max(1);
828 let bar_busy = (busy * bar_width) / total.max(1);
829 let bar_stale = (stale * bar_width) / total.max(1);
830 let bar_remainder = bar_width
832 - bar_clean
833 - bar_modified
834 - bar_active
835 - bar_pr_open
836 - bar_merged
837 - bar_busy
838 - bar_stale;
839
840 print!(" ");
841 print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
842 print!("{}", style("█".repeat(bar_modified)).yellow());
843 print!("{}", style("█".repeat(bar_active)).green().bold());
844 print!("{}", style("█".repeat(bar_pr_open)).cyan());
845 print!("{}", style("█".repeat(bar_merged)).magenta());
846 print!("{}", style("█".repeat(bar_busy)).red().bold());
847 print!("{}", style("█".repeat(bar_stale)).red());
848 println!();
849
850 let mut parts = Vec::new();
851 if clean > 0 {
852 parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
853 }
854 if modified > 0 {
855 parts.push(format!(
856 "{}",
857 style(format!("◉ {} modified", modified)).yellow()
858 ));
859 }
860 if active > 0 {
861 parts.push(format!(
862 "{}",
863 style(format!("● {} active", active)).green().bold()
864 ));
865 }
866 if pr_open > 0 {
867 parts.push(format!(
868 "{}",
869 style(format!("⬆ {} pr-open", pr_open)).cyan()
870 ));
871 }
872 if merged > 0 {
873 parts.push(format!(
874 "{}",
875 style(format!("✓ {} merged", merged)).magenta()
876 ));
877 }
878 if busy > 0 {
879 parts.push(format!(
880 "{}",
881 style(format!("🔒 {} busy", busy)).red().bold()
882 ));
883 }
884 if stale > 0 {
885 parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
886 }
887 println!(" {}", parts.join(" "));
888 println!();
889
890 let ages: Vec<f64> = data
892 .iter()
893 .filter(|d| d.age_days > 0.0)
894 .map(|d| d.age_days)
895 .collect();
896 if !ages.is_empty() {
897 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
898 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
899 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
900
901 println!(" {} Age", style("◷").dim());
902 println!(
903 " avg {} oldest {} newest {}",
904 style(format!("{:.1}d", avg)).bold(),
905 style(format!("{:.1}d", oldest)).yellow(),
906 style(format!("{:.1}d", newest)).green(),
907 );
908 println!();
909 }
910
911 let commits: Vec<usize> = data
913 .iter()
914 .filter(|d| d.commit_count > 0)
915 .map(|d| d.commit_count)
916 .collect();
917 if !commits.is_empty() {
918 let total: usize = commits.iter().sum();
919 let avg = total as f64 / commits.len() as f64;
920 let max_c = *commits.iter().max().unwrap_or(&0);
921
922 println!(" {} Commits", style("⟲").dim());
923 println!(
924 " total {} avg {:.1} max {}",
925 style(total).bold(),
926 avg,
927 style(max_c).bold(),
928 );
929 println!();
930 }
931
932 println!(" {}", style("Oldest Worktrees").bold());
934 let mut by_age = data.iter().collect::<Vec<_>>();
935 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
936 let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
937 for d in by_age.iter().take(5) {
938 if d.age_days > 0.0 {
939 let icon = cwconsole::status_icon(&d.status);
940 let st = cwconsole::status_style(&d.status);
941 let bar_len = ((d.age_days / max_age) * 15.0) as usize;
942 println!(
943 " {} {:<25} {} {}",
944 st.apply_to(icon),
945 d.branch,
946 style("▓".repeat(bar_len.max(1))).dim(),
947 style(format_age(d.age_days)).dim(),
948 );
949 }
950 }
951 println!();
952
953 println!(" {}", style("Most Active (by commits)").bold());
955 let mut by_commits = data.iter().collect::<Vec<_>>();
956 by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
957 let max_commits = by_commits
958 .first()
959 .map(|d| d.commit_count)
960 .unwrap_or(1)
961 .max(1);
962 for d in by_commits.iter().take(5) {
963 if d.commit_count > 0 {
964 let icon = cwconsole::status_icon(&d.status);
965 let st = cwconsole::status_style(&d.status);
966 let bar_len = (d.commit_count * 15) / max_commits;
967 println!(
968 " {} {:<25} {} {}",
969 st.apply_to(icon),
970 d.branch,
971 style("▓".repeat(bar_len.max(1))).cyan(),
972 style(format!("{} commits", d.commit_count)).dim(),
973 );
974 }
975 }
976 println!();
977
978 Ok(())
979}
980
981pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
983 let repo = git::get_repo_root(None)?;
984
985 if !git::branch_exists(branch1, Some(&repo)) {
986 return Err(crate::error::CwError::InvalidBranch(format!(
987 "Branch '{}' not found",
988 branch1
989 )));
990 }
991 if !git::branch_exists(branch2, Some(&repo)) {
992 return Err(crate::error::CwError::InvalidBranch(format!(
993 "Branch '{}' not found",
994 branch2
995 )));
996 }
997
998 println!("\n{}", style("Comparing branches:").cyan().bold());
999 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
1000
1001 if files {
1002 let result = git::git_command(
1003 &["diff", "--name-status", branch1, branch2],
1004 Some(&repo),
1005 true,
1006 true,
1007 )?;
1008 println!("{}\n", style("Changed files:").bold());
1009 if result.stdout.trim().is_empty() {
1010 println!(" {}", style("No differences found").dim());
1011 } else {
1012 for line in result.stdout.trim().lines() {
1013 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
1014 if parts.len() == 2 {
1015 let (status_char, filename) = (parts[0], parts[1]);
1016 let c = status_char.chars().next().unwrap_or('?');
1017 let status_name = match c {
1018 'M' => "Modified",
1019 'A' => "Added",
1020 'D' => "Deleted",
1021 'R' => "Renamed",
1022 'C' => "Copied",
1023 _ => "Changed",
1024 };
1025 let styled_status = match c {
1026 'M' => style(status_char).yellow(),
1027 'A' => style(status_char).green(),
1028 'D' => style(status_char).red(),
1029 'R' | 'C' => style(status_char).cyan(),
1030 _ => style(status_char),
1031 };
1032 println!(" {} {} ({})", styled_status, filename, status_name);
1033 }
1034 }
1035 }
1036 } else if summary {
1037 let result = git::git_command(
1038 &["diff", "--stat", branch1, branch2],
1039 Some(&repo),
1040 true,
1041 true,
1042 )?;
1043 println!("{}\n", style("Diff summary:").bold());
1044 if result.stdout.trim().is_empty() {
1045 println!(" {}", style("No differences found").dim());
1046 } else {
1047 println!("{}", result.stdout);
1048 }
1049 } else {
1050 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
1051 if result.stdout.trim().is_empty() {
1052 println!("{}\n", style("No differences found").dim());
1053 } else {
1054 println!("{}", result.stdout);
1055 }
1056 }
1057
1058 Ok(())
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 #[test]
1066 fn test_format_age_just_now() {
1067 assert_eq!(format_age(0.0), "just now");
1068 assert_eq!(format_age(0.001), "just now"); }
1070
1071 #[test]
1072 fn test_format_age_hours() {
1073 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"); }
1077
1078 #[test]
1079 fn test_format_age_days() {
1080 assert_eq!(format_age(1.0), "1d ago");
1081 assert_eq!(format_age(1.5), "1d ago");
1082 assert_eq!(format_age(6.9), "6d ago");
1083 }
1084
1085 #[test]
1086 fn test_format_age_weeks() {
1087 assert_eq!(format_age(7.0), "1w ago");
1088 assert_eq!(format_age(14.0), "2w ago");
1089 assert_eq!(format_age(29.0), "4w ago");
1090 }
1091
1092 #[test]
1093 fn test_format_age_months() {
1094 assert_eq!(format_age(30.0), "1mo ago");
1095 assert_eq!(format_age(60.0), "2mo ago");
1096 assert_eq!(format_age(364.0), "12mo ago");
1097 }
1098
1099 #[test]
1100 fn test_format_age_years() {
1101 assert_eq!(format_age(365.0), "1y ago");
1102 assert_eq!(format_age(730.0), "2y ago");
1103 }
1104
1105 #[test]
1106 fn test_format_age_boundary_below_one_hour() {
1107 assert_eq!(format_age(0.04), "just now"); }
1110
1111 #[test]
1115 #[cfg(unix)]
1116 fn test_get_worktree_status_busy_from_lockfile() {
1117 use crate::operations::lockfile::LockEntry;
1118 use std::fs;
1119 use std::process::{Command, Stdio};
1120
1121 let tmp = tempfile::TempDir::new().unwrap();
1122 let repo = tmp.path();
1123 let wt = repo.join("wt1");
1124 fs::create_dir_all(wt.join(".git")).unwrap();
1125
1126 let mut child = Command::new("sleep")
1130 .arg("30")
1131 .stdout(Stdio::null())
1132 .stderr(Stdio::null())
1133 .spawn()
1134 .expect("spawn sleep");
1135 let foreign_pid: u32 = child.id();
1136
1137 let entry = LockEntry {
1138 version: crate::operations::lockfile::LOCK_VERSION,
1139 pid: foreign_pid,
1140 started_at: 0,
1141 cmd: "claude".to_string(),
1142 };
1143 fs::write(
1144 wt.join(".git").join("gw-session.lock"),
1145 serde_json::to_string(&entry).unwrap(),
1146 )
1147 .unwrap();
1148
1149 let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
1150
1151 let _ = child.kill();
1153 let _ = child.wait();
1154
1155 assert_eq!(status, "busy");
1156 }
1157
1158 #[test]
1159 fn test_get_worktree_status_stale() {
1160 use std::path::PathBuf;
1161 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1162 let repo = PathBuf::from("/tmp");
1163 assert_eq!(
1164 get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1165 "stale"
1166 );
1167 }
1168}