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 crate::operations::busy::active_claude_sessions(path).is_some() {
73 return "busy".to_string();
74 }
75
76 if let Ok(cwd) = std::env::current_dir() {
79 let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
80 let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
81 if cwd_canon.starts_with(&path_canon) {
82 return "active".to_string();
83 }
84 }
85
86 if let Some(branch_name) = branch {
88 let base_branch = {
89 let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
90 git::get_config(&key, Some(repo))
91 .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
92 };
93
94 if let Some(state) = pr_cache.state(branch_name) {
96 match state {
97 super::pr_cache::PrState::Merged => return "merged".to_string(),
98 super::pr_cache::PrState::Open => return "pr-open".to_string(),
99 _ => {}
101 }
102 }
103
104 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
107 return "merged".to_string();
108 }
109 }
110
111 if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
113 if result.returncode == 0 && !result.stdout.trim().is_empty() {
114 return "modified".to_string();
115 }
116 }
117
118 "clean".to_string()
119}
120
121pub fn format_age(age_days: f64) -> String {
123 if age_days < 1.0 {
124 let hours = (age_days * 24.0) as i64;
125 if hours > 0 {
126 format!("{}h ago", hours)
127 } else {
128 "just now".to_string()
129 }
130 } else if age_days < 7.0 {
131 format!("{}d ago", age_days as i64)
132 } else if age_days < 30.0 {
133 format!("{}w ago", (age_days / 7.0) as i64)
134 } else if age_days < 365.0 {
135 format!("{}mo ago", (age_days / 30.0) as i64)
136 } else {
137 format!("{}y ago", (age_days / 365.0) as i64)
138 }
139}
140
141fn path_age_str(path: &Path) -> String {
143 if !path.exists() {
144 return String::new();
145 }
146 path_age_days(path).map(format_age).unwrap_or_default()
147}
148
149struct WorktreeRow {
151 worktree_id: String,
152 current_branch: String,
153 status: String,
154 age: String,
155 rel_path: String,
156 path: std::path::PathBuf,
159}
160
161#[derive(Clone)]
165struct RowInput {
166 path: std::path::PathBuf,
167 current_branch: String,
168 worktree_id: String,
169 age: String,
170 rel_path: String,
171}
172
173impl RowInput {
174 fn into_row(self, status: String) -> WorktreeRow {
175 WorktreeRow {
176 worktree_id: self.worktree_id,
177 current_branch: self.current_branch,
178 status,
179 age: self.age,
180 rel_path: self.rel_path,
181 path: self.path,
182 }
183 }
184}
185
186fn prewarm_busy_caches() {
201 std::thread::spawn(crate::operations::busy::prewarm_cwd_scan);
202 std::thread::spawn(crate::operations::claude_process::prewarm);
203}
204
205pub fn list_worktrees(no_cache: bool) -> Result<()> {
207 let repo = git::get_repo_root(None)?;
208 let worktrees = git::parse_worktrees(&repo)?;
209
210 println!(
211 "\n{} {}\n",
212 style("Worktrees for repository:").cyan().bold(),
213 repo.display()
214 );
215
216 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
217
218 let inputs: Vec<RowInput> = worktrees
220 .iter()
221 .map(|(branch, path)| {
222 let current_branch = git::normalize_branch_name(branch).to_string();
223 let rel_path = pathdiff::diff_paths(path, &repo)
224 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
225 .unwrap_or_else(|| path.to_string_lossy().to_string());
226 let age = path_age_str(path);
227 let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
228 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
229 RowInput {
230 path: path.clone(),
231 current_branch,
232 worktree_id,
233 age,
234 rel_path,
235 }
236 })
237 .collect();
238
239 if inputs.is_empty() {
240 println!(" {}\n", style("No worktrees found.").dim());
241 return Ok(());
242 }
243
244 prewarm_busy_caches();
245
246 let is_tty = crate::tui::stdout_is_tty();
247 let term_width = cwconsole::terminal_width();
250 let narrow = term_width < MIN_TABLE_WIDTH;
253 let use_progressive = is_tty && !narrow;
254
255 let rows: Vec<WorktreeRow> = if use_progressive {
256 render_rows_progressive(&repo, &pr_cache, inputs)?
257 } else {
258 inputs
260 .into_par_iter()
261 .map(|i| {
262 let status =
263 get_worktree_status(&i.path, &repo, Some(&i.current_branch), &pr_cache);
264 i.into_row(status)
265 })
266 .collect()
267 };
268
269 if !use_progressive {
272 if narrow {
273 print_worktree_compact(&rows);
274 } else {
275 print_worktree_table(&rows);
276 }
277 }
278
279 print_busy_details(&rows);
286 print_summary_footer(&rows);
287
288 println!();
289 Ok(())
290}
291
292type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
299
300struct TerminalGuard(Option<CrosstermTerminal>);
307
308impl TerminalGuard {
309 fn new(terminal: CrosstermTerminal) -> Self {
310 Self(Some(terminal))
314 }
315
316 fn as_mut(&mut self) -> &mut CrosstermTerminal {
317 self.0.as_mut().expect("terminal already taken")
318 }
319}
320
321impl Drop for TerminalGuard {
322 fn drop(&mut self) {
323 let _ = self.0.take(); ratatui::restore();
325 crate::tui::mark_ratatui_inactive();
328 }
329}
330
331fn render_rows_progressive(
332 repo: &std::path::Path,
333 pr_cache: &PrCache,
334 inputs: Vec<RowInput>,
335) -> Result<Vec<WorktreeRow>> {
336 let row_data: Vec<crate::tui::list_view::RowData> = inputs
338 .iter()
339 .map(|i| crate::tui::list_view::RowData {
340 worktree_id: i.worktree_id.clone(),
341 current_branch: i.current_branch.clone(),
342 status: crate::tui::list_view::PLACEHOLDER.to_string(),
343 age: i.age.clone(),
344 rel_path: i.rel_path.clone(),
345 })
346 .collect();
347 let mut app = crate::tui::list_view::ListApp::new(row_data);
348
349 let viewport_height = u16::try_from(inputs.len())
352 .unwrap_or(u16::MAX)
353 .saturating_add(2)
354 .max(3);
355
356 let stdout = std::io::stdout();
357 let backend = CrosstermBackend::new(stdout);
358 crate::tui::mark_ratatui_active();
365 let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
366 Terminal::with_options(
367 backend,
368 TerminalOptions {
369 viewport: Viewport::Inline(viewport_height),
370 },
371 )
372 })) {
373 Ok(Ok(t)) => t,
374 Ok(Err(e)) => {
375 crate::tui::mark_ratatui_inactive();
376 return Err(e.into());
377 }
378 Err(panic) => {
379 crate::tui::mark_ratatui_inactive();
380 std::panic::resume_unwind(panic);
381 }
382 };
383 let mut guard = TerminalGuard::new(terminal);
384 let (tx, rx) = mpsc::channel();
399
400 guard.as_mut().draw(|f| app.render(f))?;
405
406 let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
410
411 std::thread::scope(|s| -> Result<()> {
416 let producer = s.spawn(move || {
417 inputs
418 .par_iter()
419 .enumerate()
420 .for_each_with(tx, |tx, (i, input)| {
421 let status = get_worktree_status(
422 &input.path,
423 repo,
424 Some(&input.current_branch),
425 pr_cache,
426 );
427 let _ = tx.send((i, status));
428 });
429 });
430
431 let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
432 let producer_result = producer.join();
433 if let Err(panic) = producer_result {
434 let msg = panic
436 .downcast_ref::<&str>()
437 .map(|s| (*s).to_string())
438 .or_else(|| panic.downcast_ref::<String>().cloned())
439 .unwrap_or_else(|| "non-string panic payload".to_string());
440 eprintln!(
441 "warning: status producer thread panicked, some rows may show \"unknown\": {}",
442 msg
443 );
444 }
445 run_result.map_err(crate::error::CwError::from)
446 })?;
447
448 if app.finalize_pending("unknown") {
455 guard.as_mut().draw(|f| app.render(f))?;
456 }
457
458 Ok(app
459 .into_rows()
460 .into_iter()
461 .zip(paths)
462 .map(|(r, path)| WorktreeRow {
463 worktree_id: r.worktree_id,
464 current_branch: r.current_branch,
465 status: r.status,
466 age: r.age,
467 rel_path: r.rel_path,
468 path,
469 })
470 .collect())
471}
472
473fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
479 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
481 if let Some(intended) = git::get_config(&key, Some(repo)) {
482 return Some(intended);
483 }
484
485 let result = git::git_command(
487 &[
488 "config",
489 "--local",
490 "--get-regexp",
491 r"^worktree\..*\.intendedBranch",
492 ],
493 Some(repo),
494 false,
495 true,
496 )
497 .ok()?;
498
499 if result.returncode != 0 {
500 return None;
501 }
502
503 let repo_name = repo.file_name()?.to_string_lossy().to_string();
504
505 for line in result.stdout.trim().lines() {
506 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
507 if parts.len() == 2 {
508 let key_parts: Vec<&str> = parts[0].split('.').collect();
509 if key_parts.len() >= 2 {
510 let branch_from_key = key_parts[1];
511 let expected_path_name =
512 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
513 if let Some(name) = path.file_name() {
514 if name.to_string_lossy() == expected_path_name {
515 return Some(parts[1].to_string());
516 }
517 }
518 }
519 }
520 }
521
522 None
523}
524
525fn print_busy_details(rows: &[WorktreeRow]) {
535 let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
536 if busy_rows.is_empty() {
537 return;
538 }
539
540 for row in busy_rows {
541 let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
542 if hard.is_empty() && soft.is_empty() {
546 continue;
547 }
548 let block =
549 crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
550 println!();
551 print!("{}", block);
553 }
554}
555
556fn print_summary_footer(rows: &[WorktreeRow]) {
557 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
560 if feature_count == 0 {
561 return;
562 }
563
564 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
565 for row in rows {
566 *counts.entry(row.status.as_str()).or_insert(0) += 1;
567 }
568
569 let mut summary_parts = Vec::new();
570 for &status_name in &[
571 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
572 ] {
573 if let Some(&count) = counts.get(status_name) {
574 if count > 0 {
575 let styled = cwconsole::status_style(status_name)
576 .apply_to(format!("{} {}", count, status_name));
577 summary_parts.push(styled.to_string());
578 }
579 }
580 }
581
582 let summary = if summary_parts.is_empty() {
583 format!("\n{} feature worktree(s)", feature_count)
584 } else {
585 format!(
586 "\n{} feature worktree(s) — {}",
587 feature_count,
588 summary_parts.join(", ")
589 )
590 };
591 println!("{}", summary);
592}
593
594fn print_worktree_table(rows: &[WorktreeRow]) {
595 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
596 let max_br = rows
597 .iter()
598 .map(|r| r.current_branch.len())
599 .max()
600 .unwrap_or(20);
601 let wt_col = max_wt.clamp(12, 35) + 2;
602 let br_col = max_br.clamp(12, 35) + 2;
603
604 println!(
605 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
606 style(" ").dim(),
607 style("WORKTREE").dim(),
608 style("BRANCH").dim(),
609 style("STATUS").dim(),
610 style("AGE").dim(),
611 style("PATH").dim(),
612 wt_col = wt_col,
613 br_col = br_col,
614 );
615 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
616 println!(" {}", style("─".repeat(line_width)).dim());
617
618 for row in rows {
619 let icon = cwconsole::status_icon(&row.status);
620 let st = cwconsole::status_style(&row.status);
621
622 let branch_display = if row.worktree_id != row.current_branch {
623 style(format!("{} ⚠", row.current_branch))
624 .yellow()
625 .to_string()
626 } else {
627 row.current_branch.clone()
628 };
629
630 let status_styled = st.apply_to(format!("{:<10}", row.status));
631
632 println!(
633 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
634 st.apply_to(icon),
635 style(&row.worktree_id).bold(),
636 branch_display,
637 status_styled,
638 style(&row.age).dim(),
639 style(&row.rel_path).dim(),
640 wt_col = wt_col,
641 br_col = br_col,
642 );
643 }
644}
645
646fn print_worktree_compact(rows: &[WorktreeRow]) {
647 for row in rows {
648 let icon = cwconsole::status_icon(&row.status);
649 let st = cwconsole::status_style(&row.status);
650 let age_part = if row.age.is_empty() {
651 String::new()
652 } else {
653 format!(" {}", style(&row.age).dim())
654 };
655
656 println!(
657 " {} {} {}{}",
658 st.apply_to(icon),
659 style(&row.worktree_id).bold(),
660 st.apply_to(&row.status),
661 age_part,
662 );
663
664 let mut details = Vec::new();
665 if row.worktree_id != row.current_branch {
666 details.push(format!(
667 "branch: {}",
668 style(format!("{} ⚠", row.current_branch)).yellow()
669 ));
670 }
671 if !row.rel_path.is_empty() {
672 details.push(format!("{}", style(&row.rel_path).dim()));
673 }
674 if !details.is_empty() {
675 println!(" {}", details.join(" "));
676 }
677 }
678}
679
680pub fn show_status(no_cache: bool) -> Result<()> {
682 let repo = git::get_repo_root(None)?;
683
684 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
685 Ok(branch) => {
686 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
687 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
688 let base = git::get_config(&base_key, Some(&repo));
689 let base_path = git::get_config(&path_key, Some(&repo));
690
691 println!("\n{}", style("Current worktree:").cyan().bold());
692 println!(" Feature: {}", style(&branch).green());
693 println!(
694 " Base: {}",
695 style(base.as_deref().unwrap_or("N/A")).green()
696 );
697 println!(
698 " Base path: {}\n",
699 style(base_path.as_deref().unwrap_or("N/A")).blue()
700 );
701 }
702 Err(_) => {
703 println!(
704 "\n{}\n",
705 style("Current directory is not a feature worktree or is the main repository.")
706 .yellow()
707 );
708 }
709 }
710
711 list_worktrees(no_cache)
712}
713
714pub fn show_tree(no_cache: bool) -> Result<()> {
716 prewarm_busy_caches();
717 let repo = git::get_repo_root(None)?;
718 let cwd = std::env::current_dir().unwrap_or_default();
719
720 let repo_name = repo
721 .file_name()
722 .map(|n| n.to_string_lossy().to_string())
723 .unwrap_or_else(|| "repo".to_string());
724
725 println!(
726 "\n{} (base repository)",
727 style(format!("{}/", repo_name)).cyan().bold()
728 );
729 println!("{}\n", style(repo.display().to_string()).dim());
730
731 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
732
733 if feature_worktrees.is_empty() {
734 println!("{}\n", style(" (no feature worktrees)").dim());
735 return Ok(());
736 }
737
738 let mut sorted = feature_worktrees;
739 sorted.sort_by(|a, b| a.0.cmp(&b.0));
740
741 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
742
743 for (i, (branch_name, path)) in sorted.iter().enumerate() {
744 let is_last = i == sorted.len() - 1;
745 let prefix = if is_last { "└── " } else { "├── " };
746
747 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
748 let is_current = cwd
749 .to_string_lossy()
750 .starts_with(&path.to_string_lossy().to_string());
751
752 let icon = cwconsole::status_icon(&status);
753 let st = cwconsole::status_style(&status);
754
755 let branch_display = if is_current {
756 st.clone()
757 .bold()
758 .apply_to(format!("★ {}", branch_name))
759 .to_string()
760 } else {
761 st.clone().apply_to(branch_name.as_str()).to_string()
762 };
763
764 let age = path_age_str(path);
765 let age_display = if age.is_empty() {
766 String::new()
767 } else {
768 format!(" {}", style(age).dim())
769 };
770
771 println!(
772 "{}{} {}{}",
773 prefix,
774 st.apply_to(icon),
775 branch_display,
776 age_display
777 );
778
779 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
780 format!("../{}", rel.display())
781 } else {
782 path.display().to_string()
783 };
784
785 let continuation = if is_last { " " } else { "│ " };
786 println!("{}{}", continuation, style(&path_display).dim());
787 }
788
789 println!("\n{}", style("Legend:").bold());
791 println!(
792 " {} active (current)",
793 cwconsole::status_style("active").apply_to("●")
794 );
795 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
796 println!(
797 " {} modified",
798 cwconsole::status_style("modified").apply_to("◉")
799 );
800 println!(
801 " {} pr-open",
802 cwconsole::status_style("pr-open").apply_to("⬆")
803 );
804 println!(
805 " {} merged",
806 cwconsole::status_style("merged").apply_to("✓")
807 );
808 println!(
809 " {} busy (other session)",
810 cwconsole::status_style("busy").apply_to("🔒")
811 );
812 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
813 println!(
814 " {} currently active worktree\n",
815 style("★").green().bold()
816 );
817
818 Ok(())
819}
820
821pub fn show_stats(no_cache: bool) -> Result<()> {
823 prewarm_busy_caches();
824 let repo = git::get_repo_root(None)?;
825 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
826
827 if feature_worktrees.is_empty() {
828 println!("\n{}\n", style("No feature worktrees found").yellow());
829 return Ok(());
830 }
831
832 println!();
833 println!(" {}", style("Worktree Statistics").cyan().bold());
834 println!(" {}", style("─".repeat(40)).dim());
835 println!();
836
837 struct WtData {
838 branch: String,
839 status: String,
840 age_days: f64,
841 commit_count: usize,
842 }
843
844 let mut data: Vec<WtData> = Vec::new();
845
846 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
847
848 for (branch_name, path) in &feature_worktrees {
849 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
850 let age_days = path_age_days(path).unwrap_or(0.0);
851
852 let commit_count = git::git_command(
853 &["rev-list", "--count", branch_name],
854 Some(path),
855 false,
856 true,
857 )
858 .ok()
859 .and_then(|r| {
860 if r.returncode == 0 {
861 r.stdout.trim().parse::<usize>().ok()
862 } else {
863 None
864 }
865 })
866 .unwrap_or(0);
867
868 data.push(WtData {
869 branch: branch_name.clone(),
870 status,
871 age_days,
872 commit_count,
873 });
874 }
875
876 let mut status_counts: std::collections::HashMap<&str, usize> =
878 std::collections::HashMap::new();
879 for d in &data {
880 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
881 }
882
883 println!(" {} {}", style("Total:").bold(), data.len());
884
885 let total = data.len();
887 let bar_width = 30;
888 let clean = *status_counts.get("clean").unwrap_or(&0);
889 let modified = *status_counts.get("modified").unwrap_or(&0);
890 let active = *status_counts.get("active").unwrap_or(&0);
891 let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
892 let merged = *status_counts.get("merged").unwrap_or(&0);
893 let busy = *status_counts.get("busy").unwrap_or(&0);
894 let stale = *status_counts.get("stale").unwrap_or(&0);
895
896 let bar_clean = (clean * bar_width) / total.max(1);
897 let bar_modified = (modified * bar_width) / total.max(1);
898 let bar_active = (active * bar_width) / total.max(1);
899 let bar_pr_open = (pr_open * bar_width) / total.max(1);
900 let bar_merged = (merged * bar_width) / total.max(1);
901 let bar_busy = (busy * bar_width) / total.max(1);
902 let bar_stale = (stale * bar_width) / total.max(1);
903 let bar_remainder = bar_width
905 - bar_clean
906 - bar_modified
907 - bar_active
908 - bar_pr_open
909 - bar_merged
910 - bar_busy
911 - bar_stale;
912
913 print!(" ");
914 print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
915 print!("{}", style("█".repeat(bar_modified)).yellow());
916 print!("{}", style("█".repeat(bar_active)).green().bold());
917 print!("{}", style("█".repeat(bar_pr_open)).cyan());
918 print!("{}", style("█".repeat(bar_merged)).magenta());
919 print!("{}", style("█".repeat(bar_busy)).red().bold());
920 print!("{}", style("█".repeat(bar_stale)).red());
921 println!();
922
923 let mut parts = Vec::new();
924 if clean > 0 {
925 parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
926 }
927 if modified > 0 {
928 parts.push(format!(
929 "{}",
930 style(format!("◉ {} modified", modified)).yellow()
931 ));
932 }
933 if active > 0 {
934 parts.push(format!(
935 "{}",
936 style(format!("● {} active", active)).green().bold()
937 ));
938 }
939 if pr_open > 0 {
940 parts.push(format!(
941 "{}",
942 style(format!("⬆ {} pr-open", pr_open)).cyan()
943 ));
944 }
945 if merged > 0 {
946 parts.push(format!(
947 "{}",
948 style(format!("✓ {} merged", merged)).magenta()
949 ));
950 }
951 if busy > 0 {
952 parts.push(format!(
953 "{}",
954 style(format!("🔒 {} busy", busy)).red().bold()
955 ));
956 }
957 if stale > 0 {
958 parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
959 }
960 println!(" {}", parts.join(" "));
961 println!();
962
963 let ages: Vec<f64> = data
965 .iter()
966 .filter(|d| d.age_days > 0.0)
967 .map(|d| d.age_days)
968 .collect();
969 if !ages.is_empty() {
970 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
971 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
972 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
973
974 println!(" {} Age", style("◷").dim());
975 println!(
976 " avg {} oldest {} newest {}",
977 style(format!("{:.1}d", avg)).bold(),
978 style(format!("{:.1}d", oldest)).yellow(),
979 style(format!("{:.1}d", newest)).green(),
980 );
981 println!();
982 }
983
984 let commits: Vec<usize> = data
986 .iter()
987 .filter(|d| d.commit_count > 0)
988 .map(|d| d.commit_count)
989 .collect();
990 if !commits.is_empty() {
991 let total: usize = commits.iter().sum();
992 let avg = total as f64 / commits.len() as f64;
993 let max_c = *commits.iter().max().unwrap_or(&0);
994
995 println!(" {} Commits", style("⟲").dim());
996 println!(
997 " total {} avg {:.1} max {}",
998 style(total).bold(),
999 avg,
1000 style(max_c).bold(),
1001 );
1002 println!();
1003 }
1004
1005 println!(" {}", style("Oldest Worktrees").bold());
1007 let mut by_age = data.iter().collect::<Vec<_>>();
1008 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
1009 let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
1010 for d in by_age.iter().take(5) {
1011 if d.age_days > 0.0 {
1012 let icon = cwconsole::status_icon(&d.status);
1013 let st = cwconsole::status_style(&d.status);
1014 let bar_len = ((d.age_days / max_age) * 15.0) as usize;
1015 println!(
1016 " {} {:<25} {} {}",
1017 st.apply_to(icon),
1018 d.branch,
1019 style("▓".repeat(bar_len.max(1))).dim(),
1020 style(format_age(d.age_days)).dim(),
1021 );
1022 }
1023 }
1024 println!();
1025
1026 println!(" {}", style("Most Active (by commits)").bold());
1028 let mut by_commits = data.iter().collect::<Vec<_>>();
1029 by_commits.sort_by_key(|b| std::cmp::Reverse(b.commit_count));
1030 let max_commits = by_commits
1031 .first()
1032 .map(|d| d.commit_count)
1033 .unwrap_or(1)
1034 .max(1);
1035 for d in by_commits.iter().take(5) {
1036 if d.commit_count > 0 {
1037 let icon = cwconsole::status_icon(&d.status);
1038 let st = cwconsole::status_style(&d.status);
1039 let bar_len = (d.commit_count * 15) / max_commits;
1040 println!(
1041 " {} {:<25} {} {}",
1042 st.apply_to(icon),
1043 d.branch,
1044 style("▓".repeat(bar_len.max(1))).cyan(),
1045 style(format!("{} commits", d.commit_count)).dim(),
1046 );
1047 }
1048 }
1049 println!();
1050
1051 Ok(())
1052}
1053
1054pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
1056 let repo = git::get_repo_root(None)?;
1057
1058 if !git::branch_exists(branch1, Some(&repo)) {
1059 return Err(crate::error::CwError::InvalidBranch(format!(
1060 "Branch '{}' not found",
1061 branch1
1062 )));
1063 }
1064 if !git::branch_exists(branch2, Some(&repo)) {
1065 return Err(crate::error::CwError::InvalidBranch(format!(
1066 "Branch '{}' not found",
1067 branch2
1068 )));
1069 }
1070
1071 println!("\n{}", style("Comparing branches:").cyan().bold());
1072 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
1073
1074 if files {
1075 let result = git::git_command(
1076 &["diff", "--name-status", branch1, branch2],
1077 Some(&repo),
1078 true,
1079 true,
1080 )?;
1081 println!("{}\n", style("Changed files:").bold());
1082 if result.stdout.trim().is_empty() {
1083 println!(" {}", style("No differences found").dim());
1084 } else {
1085 for line in result.stdout.trim().lines() {
1086 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
1087 if parts.len() == 2 {
1088 let (status_char, filename) = (parts[0], parts[1]);
1089 let c = status_char.chars().next().unwrap_or('?');
1090 let status_name = match c {
1091 'M' => "Modified",
1092 'A' => "Added",
1093 'D' => "Deleted",
1094 'R' => "Renamed",
1095 'C' => "Copied",
1096 _ => "Changed",
1097 };
1098 let styled_status = match c {
1099 'M' => style(status_char).yellow(),
1100 'A' => style(status_char).green(),
1101 'D' => style(status_char).red(),
1102 'R' | 'C' => style(status_char).cyan(),
1103 _ => style(status_char),
1104 };
1105 println!(" {} {} ({})", styled_status, filename, status_name);
1106 }
1107 }
1108 }
1109 } else if summary {
1110 let result = git::git_command(
1111 &["diff", "--stat", branch1, branch2],
1112 Some(&repo),
1113 true,
1114 true,
1115 )?;
1116 println!("{}\n", style("Diff summary:").bold());
1117 if result.stdout.trim().is_empty() {
1118 println!(" {}", style("No differences found").dim());
1119 } else {
1120 println!("{}", result.stdout);
1121 }
1122 } else {
1123 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
1124 if result.stdout.trim().is_empty() {
1125 println!("{}\n", style("No differences found").dim());
1126 } else {
1127 println!("{}", result.stdout);
1128 }
1129 }
1130
1131 Ok(())
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137
1138 #[test]
1139 fn test_format_age_just_now() {
1140 assert_eq!(format_age(0.0), "just now");
1141 assert_eq!(format_age(0.001), "just now"); }
1143
1144 #[test]
1145 fn test_format_age_hours() {
1146 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"); }
1150
1151 #[test]
1152 fn test_format_age_days() {
1153 assert_eq!(format_age(1.0), "1d ago");
1154 assert_eq!(format_age(1.5), "1d ago");
1155 assert_eq!(format_age(6.9), "6d ago");
1156 }
1157
1158 #[test]
1159 fn test_format_age_weeks() {
1160 assert_eq!(format_age(7.0), "1w ago");
1161 assert_eq!(format_age(14.0), "2w ago");
1162 assert_eq!(format_age(29.0), "4w ago");
1163 }
1164
1165 #[test]
1166 fn test_format_age_months() {
1167 assert_eq!(format_age(30.0), "1mo ago");
1168 assert_eq!(format_age(60.0), "2mo ago");
1169 assert_eq!(format_age(364.0), "12mo ago");
1170 }
1171
1172 #[test]
1173 fn test_format_age_years() {
1174 assert_eq!(format_age(365.0), "1y ago");
1175 assert_eq!(format_age(730.0), "2y ago");
1176 }
1177
1178 #[test]
1179 fn test_format_age_boundary_below_one_hour() {
1180 assert_eq!(format_age(0.04), "just now"); }
1183
1184 #[test]
1188 #[cfg(unix)]
1189 fn test_get_worktree_status_busy_from_lockfile() {
1190 use crate::operations::lockfile::LockEntry;
1191 use std::fs;
1192 use std::process::{Command, Stdio};
1193
1194 let tmp = tempfile::TempDir::new().unwrap();
1195 let repo = tmp.path();
1196 let wt = repo.join("wt1");
1197 fs::create_dir_all(wt.join(".git")).unwrap();
1198
1199 let mut child = Command::new("sleep")
1203 .arg("30")
1204 .stdout(Stdio::null())
1205 .stderr(Stdio::null())
1206 .spawn()
1207 .expect("spawn sleep");
1208 let foreign_pid: u32 = child.id();
1209
1210 let entry = LockEntry {
1211 version: crate::operations::lockfile::LOCK_VERSION,
1212 pid: foreign_pid,
1213 started_at: 0,
1214 cmd: "claude".to_string(),
1215 };
1216 fs::write(
1217 wt.join(".git").join("gw-session.lock"),
1218 serde_json::to_string(&entry).unwrap(),
1219 )
1220 .unwrap();
1221
1222 let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
1223
1224 let _ = child.kill();
1226 let _ = child.wait();
1227
1228 assert_eq!(status, "busy");
1229 }
1230
1231 #[test]
1237 #[cfg(any(target_os = "linux", target_os = "macos"))]
1238 fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
1239 use crate::operations::test_env::{env_lock, EnvGuard};
1240 let _lock = env_lock();
1241 let _guard = EnvGuard::capture(&["HOME"]);
1242
1243 let home = tempfile::TempDir::new().unwrap();
1244 std::env::set_var("HOME", home.path());
1245
1246 let repo = tempfile::TempDir::new().unwrap();
1247 let wt = repo.path().join("wt1");
1248 std::fs::create_dir_all(wt.join(".git")).unwrap();
1249 let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
1250
1251 let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
1254 let proj_dir = home.path().join(".claude").join("projects").join(encoded);
1255 std::fs::create_dir_all(&proj_dir).unwrap();
1256 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1257 let line = serde_json::json!({
1258 "timestamp": now,
1259 "cwd": wt_canon.to_string_lossy(),
1260 });
1261 std::fs::write(
1262 proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
1263 format!("{}\n", line),
1264 )
1265 .unwrap();
1266
1267 let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
1268 assert_ne!(
1269 status, "busy",
1270 "expected non-busy without a live claude process, got busy"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_get_worktree_status_stale() {
1276 use std::path::PathBuf;
1277 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1278 let repo = PathBuf::from("/tmp");
1279 assert_eq!(
1280 get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1281 "stale"
1282 );
1283 }
1284}