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