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
141pub fn format_selector_row(
155 branch: &str,
156 age: &str,
157 busy: bool,
158 path: &str,
159 branch_col: usize,
160) -> String {
161 const AGE_COL: usize = 9;
162 const BUSY_COL: usize = 7;
163 let busy_cell: String = if busy {
164 format!("{} ", style("[busy]").yellow())
166 } else {
167 " ".repeat(BUSY_COL)
168 };
169 format!(
170 "{branch:<branch_col$} {age:<AGE_COL$} {busy_cell}{path}",
171 branch = branch,
172 age = age,
173 busy_cell = busy_cell,
174 path = path,
175 branch_col = branch_col,
176 AGE_COL = AGE_COL,
177 )
178}
179
180fn path_age_str(path: &Path) -> String {
182 if !path.exists() {
183 return String::new();
184 }
185 path_age_days(path).map(format_age).unwrap_or_default()
186}
187
188struct WorktreeRow {
190 worktree_id: String,
191 current_branch: String,
192 status: String,
193 age: String,
194 rel_path: String,
195 path: std::path::PathBuf,
198}
199
200#[derive(Clone)]
204struct RowInput {
205 path: std::path::PathBuf,
206 current_branch: String,
207 worktree_id: String,
208 age: String,
209 rel_path: String,
210}
211
212impl RowInput {
213 fn into_row(self, status: String) -> WorktreeRow {
214 WorktreeRow {
215 worktree_id: self.worktree_id,
216 current_branch: self.current_branch,
217 status,
218 age: self.age,
219 rel_path: self.rel_path,
220 path: self.path,
221 }
222 }
223}
224
225fn prewarm_busy_caches() {
240 std::thread::spawn(crate::operations::busy::prewarm_cwd_scan);
241 std::thread::spawn(crate::operations::claude_process::prewarm);
242}
243
244pub fn list_worktrees(no_cache: bool) -> Result<()> {
246 let repo = git::get_repo_root(None)?;
247 let worktrees = git::parse_worktrees(&repo)?;
248
249 println!(
250 "\n{} {}\n",
251 style("Worktrees for repository:").cyan().bold(),
252 repo.display()
253 );
254
255 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
256
257 let inputs: Vec<RowInput> = worktrees
259 .iter()
260 .map(|(branch, path)| {
261 let current_branch = git::normalize_branch_name(branch).to_string();
262 let rel_path = pathdiff::diff_paths(path, &repo)
263 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
264 .unwrap_or_else(|| path.to_string_lossy().to_string());
265 let age = path_age_str(path);
266 let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
267 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
268 RowInput {
269 path: path.clone(),
270 current_branch,
271 worktree_id,
272 age,
273 rel_path,
274 }
275 })
276 .collect();
277
278 if inputs.is_empty() {
279 println!(" {}\n", style("No worktrees found.").dim());
280 return Ok(());
281 }
282
283 prewarm_busy_caches();
284
285 let is_tty = crate::tui::stdout_is_tty();
286 let term_width = cwconsole::terminal_width();
289 let narrow = term_width < MIN_TABLE_WIDTH;
292 let use_progressive = is_tty && !narrow;
293
294 let rows: Vec<WorktreeRow> = if use_progressive {
295 render_rows_progressive(&repo, &pr_cache, inputs)?
296 } else {
297 inputs
299 .into_par_iter()
300 .map(|i| {
301 let status =
302 get_worktree_status(&i.path, &repo, Some(&i.current_branch), &pr_cache);
303 i.into_row(status)
304 })
305 .collect()
306 };
307
308 if !use_progressive {
311 if narrow {
312 print_worktree_compact(&rows);
313 } else {
314 print_worktree_table(&rows);
315 }
316 }
317
318 print_busy_details(&rows);
325 print_summary_footer(&rows);
326
327 println!();
328 Ok(())
329}
330
331type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
338
339struct TerminalGuard(Option<CrosstermTerminal>);
346
347impl TerminalGuard {
348 fn new(terminal: CrosstermTerminal) -> Self {
349 Self(Some(terminal))
353 }
354
355 fn as_mut(&mut self) -> &mut CrosstermTerminal {
356 self.0.as_mut().expect("terminal already taken")
357 }
358}
359
360impl Drop for TerminalGuard {
361 fn drop(&mut self) {
362 let _ = self.0.take(); ratatui::restore();
364 crate::tui::mark_ratatui_inactive();
367 }
368}
369
370fn render_rows_progressive(
371 repo: &std::path::Path,
372 pr_cache: &PrCache,
373 inputs: Vec<RowInput>,
374) -> Result<Vec<WorktreeRow>> {
375 let row_data: Vec<crate::tui::list_view::RowData> = inputs
377 .iter()
378 .map(|i| crate::tui::list_view::RowData {
379 worktree_id: i.worktree_id.clone(),
380 current_branch: i.current_branch.clone(),
381 status: crate::tui::list_view::PLACEHOLDER.to_string(),
382 age: i.age.clone(),
383 rel_path: i.rel_path.clone(),
384 })
385 .collect();
386 let mut app = crate::tui::list_view::ListApp::new(row_data);
387
388 let viewport_height = u16::try_from(inputs.len())
391 .unwrap_or(u16::MAX)
392 .saturating_add(2)
393 .max(3);
394
395 let stdout = std::io::stdout();
396 let backend = CrosstermBackend::new(stdout);
397 crate::tui::mark_ratatui_active();
404 let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
405 Terminal::with_options(
406 backend,
407 TerminalOptions {
408 viewport: Viewport::Inline(viewport_height),
409 },
410 )
411 })) {
412 Ok(Ok(t)) => t,
413 Ok(Err(e)) => {
414 crate::tui::mark_ratatui_inactive();
415 return Err(e.into());
416 }
417 Err(panic) => {
418 crate::tui::mark_ratatui_inactive();
419 std::panic::resume_unwind(panic);
420 }
421 };
422 let mut guard = TerminalGuard::new(terminal);
423 let (tx, rx) = mpsc::channel();
438
439 guard.as_mut().draw(|f| app.render(f))?;
444
445 let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
449
450 std::thread::scope(|s| -> Result<()> {
455 let producer = s.spawn(move || {
456 inputs
457 .par_iter()
458 .enumerate()
459 .for_each_with(tx, |tx, (i, input)| {
460 let status = get_worktree_status(
461 &input.path,
462 repo,
463 Some(&input.current_branch),
464 pr_cache,
465 );
466 let _ = tx.send((i, status));
467 });
468 });
469
470 let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
471 let producer_result = producer.join();
472 if let Err(panic) = producer_result {
473 let msg = panic
475 .downcast_ref::<&str>()
476 .map(|s| (*s).to_string())
477 .or_else(|| panic.downcast_ref::<String>().cloned())
478 .unwrap_or_else(|| "non-string panic payload".to_string());
479 eprintln!(
480 "warning: status producer thread panicked, some rows may show \"unknown\": {}",
481 msg
482 );
483 }
484 run_result.map_err(crate::error::CwError::from)
485 })?;
486
487 if app.finalize_pending("unknown") {
494 guard.as_mut().draw(|f| app.render(f))?;
495 }
496
497 Ok(app
498 .into_rows()
499 .into_iter()
500 .zip(paths)
501 .map(|(r, path)| WorktreeRow {
502 worktree_id: r.worktree_id,
503 current_branch: r.current_branch,
504 status: r.status,
505 age: r.age,
506 rel_path: r.rel_path,
507 path,
508 })
509 .collect())
510}
511
512fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
518 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
520 if let Some(intended) = git::get_config(&key, Some(repo)) {
521 return Some(intended);
522 }
523
524 let result = git::git_command(
526 &[
527 "config",
528 "--local",
529 "--get-regexp",
530 r"^worktree\..*\.intendedBranch",
531 ],
532 Some(repo),
533 false,
534 true,
535 )
536 .ok()?;
537
538 if result.returncode != 0 {
539 return None;
540 }
541
542 let repo_name = repo.file_name()?.to_string_lossy().to_string();
543
544 for line in result.stdout.trim().lines() {
545 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
546 if parts.len() == 2 {
547 let key_parts: Vec<&str> = parts[0].split('.').collect();
548 if key_parts.len() >= 2 {
549 let branch_from_key = key_parts[1];
550 let expected_path_name =
551 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
552 if let Some(name) = path.file_name() {
553 if name.to_string_lossy() == expected_path_name {
554 return Some(parts[1].to_string());
555 }
556 }
557 }
558 }
559 }
560
561 None
562}
563
564fn print_busy_details(rows: &[WorktreeRow]) {
574 let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
575 if busy_rows.is_empty() {
576 return;
577 }
578
579 for row in busy_rows {
580 let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
581 if hard.is_empty() && soft.is_empty() {
585 continue;
586 }
587 let block =
588 crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
589 println!();
590 print!("{}", block);
592 }
593}
594
595fn print_summary_footer(rows: &[WorktreeRow]) {
596 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
599 if feature_count == 0 {
600 return;
601 }
602
603 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
604 for row in rows {
605 *counts.entry(row.status.as_str()).or_insert(0) += 1;
606 }
607
608 let mut summary_parts = Vec::new();
609 for &status_name in &[
610 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
611 ] {
612 if let Some(&count) = counts.get(status_name) {
613 if count > 0 {
614 let styled = cwconsole::status_style(status_name)
615 .apply_to(format!("{} {}", count, status_name));
616 summary_parts.push(styled.to_string());
617 }
618 }
619 }
620
621 let summary = if summary_parts.is_empty() {
622 format!("\n{} feature worktree(s)", feature_count)
623 } else {
624 format!(
625 "\n{} feature worktree(s) — {}",
626 feature_count,
627 summary_parts.join(", ")
628 )
629 };
630 println!("{}", summary);
631}
632
633fn print_worktree_table(rows: &[WorktreeRow]) {
634 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
635 let max_br = rows
636 .iter()
637 .map(|r| r.current_branch.len())
638 .max()
639 .unwrap_or(20);
640 let wt_col = max_wt.clamp(12, 35) + 2;
641 let br_col = max_br.clamp(12, 35) + 2;
642
643 println!(
644 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
645 style(" ").dim(),
646 style("WORKTREE").dim(),
647 style("BRANCH").dim(),
648 style("STATUS").dim(),
649 style("AGE").dim(),
650 style("PATH").dim(),
651 wt_col = wt_col,
652 br_col = br_col,
653 );
654 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
655 println!(" {}", style("─".repeat(line_width)).dim());
656
657 for row in rows {
658 let icon = cwconsole::status_icon(&row.status);
659 let st = cwconsole::status_style(&row.status);
660
661 let branch_display = if row.worktree_id != row.current_branch {
662 style(format!("{} ⚠", row.current_branch))
663 .yellow()
664 .to_string()
665 } else {
666 row.current_branch.clone()
667 };
668
669 let status_styled = st.apply_to(format!("{:<10}", row.status));
670
671 println!(
672 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
673 st.apply_to(icon),
674 style(&row.worktree_id).bold(),
675 branch_display,
676 status_styled,
677 style(&row.age).dim(),
678 style(&row.rel_path).dim(),
679 wt_col = wt_col,
680 br_col = br_col,
681 );
682 }
683}
684
685fn print_worktree_compact(rows: &[WorktreeRow]) {
686 for row in rows {
687 let icon = cwconsole::status_icon(&row.status);
688 let st = cwconsole::status_style(&row.status);
689 let age_part = if row.age.is_empty() {
690 String::new()
691 } else {
692 format!(" {}", style(&row.age).dim())
693 };
694
695 println!(
696 " {} {} {}{}",
697 st.apply_to(icon),
698 style(&row.worktree_id).bold(),
699 st.apply_to(&row.status),
700 age_part,
701 );
702
703 let mut details = Vec::new();
704 if row.worktree_id != row.current_branch {
705 details.push(format!(
706 "branch: {}",
707 style(format!("{} ⚠", row.current_branch)).yellow()
708 ));
709 }
710 if !row.rel_path.is_empty() {
711 details.push(format!("{}", style(&row.rel_path).dim()));
712 }
713 if !details.is_empty() {
714 println!(" {}", details.join(" "));
715 }
716 }
717}
718
719pub fn show_status(no_cache: bool) -> Result<()> {
721 let repo = git::get_repo_root(None)?;
722
723 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
724 Ok(branch) => {
725 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
726 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
727 let base = git::get_config(&base_key, Some(&repo));
728 let base_path = git::get_config(&path_key, Some(&repo));
729
730 println!("\n{}", style("Current worktree:").cyan().bold());
731 println!(" Feature: {}", style(&branch).green());
732 println!(
733 " Base: {}",
734 style(base.as_deref().unwrap_or("N/A")).green()
735 );
736 println!(
737 " Base path: {}\n",
738 style(base_path.as_deref().unwrap_or("N/A")).blue()
739 );
740 }
741 Err(_) => {
742 println!(
743 "\n{}\n",
744 style("Current directory is not a feature worktree or is the main repository.")
745 .yellow()
746 );
747 }
748 }
749
750 list_worktrees(no_cache)
751}
752
753pub fn show_tree(no_cache: bool) -> Result<()> {
755 prewarm_busy_caches();
756 let repo = git::get_repo_root(None)?;
757 let cwd = std::env::current_dir().unwrap_or_default();
758
759 let repo_name = repo
760 .file_name()
761 .map(|n| n.to_string_lossy().to_string())
762 .unwrap_or_else(|| "repo".to_string());
763
764 println!(
765 "\n{} (base repository)",
766 style(format!("{}/", repo_name)).cyan().bold()
767 );
768 println!("{}\n", style(repo.display().to_string()).dim());
769
770 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
771
772 if feature_worktrees.is_empty() {
773 println!("{}\n", style(" (no feature worktrees)").dim());
774 return Ok(());
775 }
776
777 let mut sorted = feature_worktrees;
778 sorted.sort_by(|a, b| a.0.cmp(&b.0));
779
780 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
781
782 for (i, (branch_name, path)) in sorted.iter().enumerate() {
783 let is_last = i == sorted.len() - 1;
784 let prefix = if is_last { "└── " } else { "├── " };
785
786 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
787 let is_current = cwd
788 .to_string_lossy()
789 .starts_with(&path.to_string_lossy().to_string());
790
791 let icon = cwconsole::status_icon(&status);
792 let st = cwconsole::status_style(&status);
793
794 let branch_display = if is_current {
795 st.clone()
796 .bold()
797 .apply_to(format!("★ {}", branch_name))
798 .to_string()
799 } else {
800 st.clone().apply_to(branch_name.as_str()).to_string()
801 };
802
803 let age = path_age_str(path);
804 let age_display = if age.is_empty() {
805 String::new()
806 } else {
807 format!(" {}", style(age).dim())
808 };
809
810 println!(
811 "{}{} {}{}",
812 prefix,
813 st.apply_to(icon),
814 branch_display,
815 age_display
816 );
817
818 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
819 format!("../{}", rel.display())
820 } else {
821 path.display().to_string()
822 };
823
824 let continuation = if is_last { " " } else { "│ " };
825 println!("{}{}", continuation, style(&path_display).dim());
826 }
827
828 println!("\n{}", style("Legend:").bold());
830 println!(
831 " {} active (current)",
832 cwconsole::status_style("active").apply_to("●")
833 );
834 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
835 println!(
836 " {} modified",
837 cwconsole::status_style("modified").apply_to("◉")
838 );
839 println!(
840 " {} pr-open",
841 cwconsole::status_style("pr-open").apply_to("⬆")
842 );
843 println!(
844 " {} merged",
845 cwconsole::status_style("merged").apply_to("✓")
846 );
847 println!(
848 " {} busy (other session)",
849 cwconsole::status_style("busy").apply_to("🔒")
850 );
851 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
852 println!(
853 " {} currently active worktree\n",
854 style("★").green().bold()
855 );
856
857 Ok(())
858}
859
860pub fn show_stats(no_cache: bool) -> Result<()> {
862 prewarm_busy_caches();
863 let repo = git::get_repo_root(None)?;
864 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
865
866 if feature_worktrees.is_empty() {
867 println!("\n{}\n", style("No feature worktrees found").yellow());
868 return Ok(());
869 }
870
871 println!();
872 println!(" {}", style("Worktree Statistics").cyan().bold());
873 println!(" {}", style("─".repeat(40)).dim());
874 println!();
875
876 struct WtData {
877 branch: String,
878 status: String,
879 age_days: f64,
880 commit_count: usize,
881 }
882
883 let mut data: Vec<WtData> = Vec::new();
884
885 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
886
887 for (branch_name, path) in &feature_worktrees {
888 let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
889 let age_days = path_age_days(path).unwrap_or(0.0);
890
891 let commit_count = git::git_command(
892 &["rev-list", "--count", branch_name],
893 Some(path),
894 false,
895 true,
896 )
897 .ok()
898 .and_then(|r| {
899 if r.returncode == 0 {
900 r.stdout.trim().parse::<usize>().ok()
901 } else {
902 None
903 }
904 })
905 .unwrap_or(0);
906
907 data.push(WtData {
908 branch: branch_name.clone(),
909 status,
910 age_days,
911 commit_count,
912 });
913 }
914
915 let mut status_counts: std::collections::HashMap<&str, usize> =
917 std::collections::HashMap::new();
918 for d in &data {
919 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
920 }
921
922 println!(" {} {}", style("Total:").bold(), data.len());
923
924 let total = data.len();
926 let bar_width = 30;
927 let clean = *status_counts.get("clean").unwrap_or(&0);
928 let modified = *status_counts.get("modified").unwrap_or(&0);
929 let active = *status_counts.get("active").unwrap_or(&0);
930 let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
931 let merged = *status_counts.get("merged").unwrap_or(&0);
932 let busy = *status_counts.get("busy").unwrap_or(&0);
933 let stale = *status_counts.get("stale").unwrap_or(&0);
934
935 let bar_clean = (clean * bar_width) / total.max(1);
936 let bar_modified = (modified * bar_width) / total.max(1);
937 let bar_active = (active * bar_width) / total.max(1);
938 let bar_pr_open = (pr_open * bar_width) / total.max(1);
939 let bar_merged = (merged * bar_width) / total.max(1);
940 let bar_busy = (busy * bar_width) / total.max(1);
941 let bar_stale = (stale * bar_width) / total.max(1);
942 let bar_remainder = bar_width
944 - bar_clean
945 - bar_modified
946 - bar_active
947 - bar_pr_open
948 - bar_merged
949 - bar_busy
950 - bar_stale;
951
952 print!(" ");
953 print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
954 print!("{}", style("█".repeat(bar_modified)).yellow());
955 print!("{}", style("█".repeat(bar_active)).green().bold());
956 print!("{}", style("█".repeat(bar_pr_open)).cyan());
957 print!("{}", style("█".repeat(bar_merged)).magenta());
958 print!("{}", style("█".repeat(bar_busy)).red().bold());
959 print!("{}", style("█".repeat(bar_stale)).red());
960 println!();
961
962 let mut parts = Vec::new();
963 if clean > 0 {
964 parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
965 }
966 if modified > 0 {
967 parts.push(format!(
968 "{}",
969 style(format!("◉ {} modified", modified)).yellow()
970 ));
971 }
972 if active > 0 {
973 parts.push(format!(
974 "{}",
975 style(format!("● {} active", active)).green().bold()
976 ));
977 }
978 if pr_open > 0 {
979 parts.push(format!(
980 "{}",
981 style(format!("⬆ {} pr-open", pr_open)).cyan()
982 ));
983 }
984 if merged > 0 {
985 parts.push(format!(
986 "{}",
987 style(format!("✓ {} merged", merged)).magenta()
988 ));
989 }
990 if busy > 0 {
991 parts.push(format!(
992 "{}",
993 style(format!("🔒 {} busy", busy)).red().bold()
994 ));
995 }
996 if stale > 0 {
997 parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
998 }
999 println!(" {}", parts.join(" "));
1000 println!();
1001
1002 let ages: Vec<f64> = data
1004 .iter()
1005 .filter(|d| d.age_days > 0.0)
1006 .map(|d| d.age_days)
1007 .collect();
1008 if !ages.is_empty() {
1009 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
1010 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
1011 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
1012
1013 println!(" {} Age", style("◷").dim());
1014 println!(
1015 " avg {} oldest {} newest {}",
1016 style(format!("{:.1}d", avg)).bold(),
1017 style(format!("{:.1}d", oldest)).yellow(),
1018 style(format!("{:.1}d", newest)).green(),
1019 );
1020 println!();
1021 }
1022
1023 let commits: Vec<usize> = data
1025 .iter()
1026 .filter(|d| d.commit_count > 0)
1027 .map(|d| d.commit_count)
1028 .collect();
1029 if !commits.is_empty() {
1030 let total: usize = commits.iter().sum();
1031 let avg = total as f64 / commits.len() as f64;
1032 let max_c = *commits.iter().max().unwrap_or(&0);
1033
1034 println!(" {} Commits", style("⟲").dim());
1035 println!(
1036 " total {} avg {:.1} max {}",
1037 style(total).bold(),
1038 avg,
1039 style(max_c).bold(),
1040 );
1041 println!();
1042 }
1043
1044 println!(" {}", style("Oldest Worktrees").bold());
1046 let mut by_age = data.iter().collect::<Vec<_>>();
1047 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
1048 let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
1049 for d in by_age.iter().take(5) {
1050 if d.age_days > 0.0 {
1051 let icon = cwconsole::status_icon(&d.status);
1052 let st = cwconsole::status_style(&d.status);
1053 let bar_len = ((d.age_days / max_age) * 15.0) as usize;
1054 println!(
1055 " {} {:<25} {} {}",
1056 st.apply_to(icon),
1057 d.branch,
1058 style("▓".repeat(bar_len.max(1))).dim(),
1059 style(format_age(d.age_days)).dim(),
1060 );
1061 }
1062 }
1063 println!();
1064
1065 println!(" {}", style("Most Active (by commits)").bold());
1067 let mut by_commits = data.iter().collect::<Vec<_>>();
1068 by_commits.sort_by_key(|b| std::cmp::Reverse(b.commit_count));
1069 let max_commits = by_commits
1070 .first()
1071 .map(|d| d.commit_count)
1072 .unwrap_or(1)
1073 .max(1);
1074 for d in by_commits.iter().take(5) {
1075 if d.commit_count > 0 {
1076 let icon = cwconsole::status_icon(&d.status);
1077 let st = cwconsole::status_style(&d.status);
1078 let bar_len = (d.commit_count * 15) / max_commits;
1079 println!(
1080 " {} {:<25} {} {}",
1081 st.apply_to(icon),
1082 d.branch,
1083 style("▓".repeat(bar_len.max(1))).cyan(),
1084 style(format!("{} commits", d.commit_count)).dim(),
1085 );
1086 }
1087 }
1088 println!();
1089
1090 Ok(())
1091}
1092
1093pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
1095 let repo = git::get_repo_root(None)?;
1096
1097 if !git::branch_exists(branch1, Some(&repo)) {
1098 return Err(crate::error::CwError::InvalidBranch(format!(
1099 "Branch '{}' not found",
1100 branch1
1101 )));
1102 }
1103 if !git::branch_exists(branch2, Some(&repo)) {
1104 return Err(crate::error::CwError::InvalidBranch(format!(
1105 "Branch '{}' not found",
1106 branch2
1107 )));
1108 }
1109
1110 println!("\n{}", style("Comparing branches:").cyan().bold());
1111 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
1112
1113 if files {
1114 let result = git::git_command(
1115 &["diff", "--name-status", branch1, branch2],
1116 Some(&repo),
1117 true,
1118 true,
1119 )?;
1120 println!("{}\n", style("Changed files:").bold());
1121 if result.stdout.trim().is_empty() {
1122 println!(" {}", style("No differences found").dim());
1123 } else {
1124 for line in result.stdout.trim().lines() {
1125 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
1126 if parts.len() == 2 {
1127 let (status_char, filename) = (parts[0], parts[1]);
1128 let c = status_char.chars().next().unwrap_or('?');
1129 let status_name = match c {
1130 'M' => "Modified",
1131 'A' => "Added",
1132 'D' => "Deleted",
1133 'R' => "Renamed",
1134 'C' => "Copied",
1135 _ => "Changed",
1136 };
1137 let styled_status = match c {
1138 'M' => style(status_char).yellow(),
1139 'A' => style(status_char).green(),
1140 'D' => style(status_char).red(),
1141 'R' | 'C' => style(status_char).cyan(),
1142 _ => style(status_char),
1143 };
1144 println!(" {} {} ({})", styled_status, filename, status_name);
1145 }
1146 }
1147 }
1148 } else if summary {
1149 let result = git::git_command(
1150 &["diff", "--stat", branch1, branch2],
1151 Some(&repo),
1152 true,
1153 true,
1154 )?;
1155 println!("{}\n", style("Diff summary:").bold());
1156 if result.stdout.trim().is_empty() {
1157 println!(" {}", style("No differences found").dim());
1158 } else {
1159 println!("{}", result.stdout);
1160 }
1161 } else {
1162 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
1163 if result.stdout.trim().is_empty() {
1164 println!("{}\n", style("No differences found").dim());
1165 } else {
1166 println!("{}", result.stdout);
1167 }
1168 }
1169
1170 Ok(())
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176
1177 #[test]
1178 fn test_format_age_just_now() {
1179 assert_eq!(format_age(0.0), "just now");
1180 assert_eq!(format_age(0.001), "just now"); }
1182
1183 #[test]
1184 fn test_format_age_hours() {
1185 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"); }
1189
1190 #[test]
1191 fn test_format_age_days() {
1192 assert_eq!(format_age(1.0), "1d ago");
1193 assert_eq!(format_age(1.5), "1d ago");
1194 assert_eq!(format_age(6.9), "6d ago");
1195 }
1196
1197 #[test]
1198 fn test_format_age_weeks() {
1199 assert_eq!(format_age(7.0), "1w ago");
1200 assert_eq!(format_age(14.0), "2w ago");
1201 assert_eq!(format_age(29.0), "4w ago");
1202 }
1203
1204 #[test]
1205 fn test_format_age_months() {
1206 assert_eq!(format_age(30.0), "1mo ago");
1207 assert_eq!(format_age(60.0), "2mo ago");
1208 assert_eq!(format_age(364.0), "12mo ago");
1209 }
1210
1211 #[test]
1212 fn test_format_age_years() {
1213 assert_eq!(format_age(365.0), "1y ago");
1214 assert_eq!(format_age(730.0), "2y ago");
1215 }
1216
1217 #[test]
1218 fn test_format_age_boundary_below_one_hour() {
1219 assert_eq!(format_age(0.04), "just now"); }
1222
1223 #[test]
1224 fn format_selector_row_no_busy() {
1225 let row = format_selector_row("feat/a", "2d ago", false, "feat-a", 30);
1226 assert_eq!(
1228 row,
1229 "feat/a 2d ago feat-a"
1230 );
1231 }
1232
1233 #[test]
1234 fn format_selector_row_busy_contains_badge() {
1235 let row = format_selector_row("fix/b", "3w ago", true, "fix-b", 30);
1236 assert!(
1237 row.contains("[busy]"),
1238 "expected [busy] in row, got: {:?}",
1239 row
1240 );
1241 assert!(row.contains("fix/b"));
1242 assert!(row.contains("3w ago"));
1243 assert!(row.contains("fix-b"));
1244 }
1245
1246 #[test]
1247 fn format_selector_row_busy_visible_width_matches_no_busy() {
1248 let plain = format_selector_row("x", "1d ago", false, "p", 30);
1251 let busy = format_selector_row("x", "1d ago", true, "p", 30);
1252 assert_eq!(
1253 crate::tui::arrow_select::visible_len(&plain),
1254 crate::tui::arrow_select::visible_len(&busy),
1255 );
1256 }
1257
1258 #[test]
1259 fn format_selector_row_empty_age_pads_to_nine() {
1260 let row = format_selector_row("feat/a", "", false, "feat-a", 30);
1261 assert_eq!(&row[48..], "feat-a");
1264 }
1265
1266 #[test]
1267 fn format_selector_row_long_branch_does_not_truncate() {
1268 let branch = "feat/extra-long-branch-name-well-past-thirty-chars";
1269 let row = format_selector_row(branch, "1d ago", false, "p", 30);
1270 assert!(
1271 row.starts_with(branch),
1272 "branch must not be truncated, got: {:?}",
1273 row
1274 );
1275 }
1276
1277 #[test]
1281 #[cfg(unix)]
1282 fn test_get_worktree_status_busy_from_lockfile() {
1283 use crate::operations::lockfile::LockEntry;
1284 use std::fs;
1285 use std::process::{Command, Stdio};
1286
1287 let tmp = tempfile::TempDir::new().unwrap();
1288 let repo = tmp.path();
1289 let wt = repo.join("wt1");
1290 fs::create_dir_all(wt.join(".git")).unwrap();
1291
1292 let mut child = Command::new("sleep")
1296 .arg("30")
1297 .stdout(Stdio::null())
1298 .stderr(Stdio::null())
1299 .spawn()
1300 .expect("spawn sleep");
1301 let foreign_pid: u32 = child.id();
1302
1303 let entry = LockEntry {
1304 version: crate::operations::lockfile::LOCK_VERSION,
1305 pid: foreign_pid,
1306 started_at: 0,
1307 cmd: "claude".to_string(),
1308 };
1309 fs::write(
1310 wt.join(".git").join("gw-session.lock"),
1311 serde_json::to_string(&entry).unwrap(),
1312 )
1313 .unwrap();
1314
1315 let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
1316
1317 let _ = child.kill();
1319 let _ = child.wait();
1320
1321 assert_eq!(status, "busy");
1322 }
1323
1324 #[test]
1330 #[cfg(any(target_os = "linux", target_os = "macos"))]
1331 fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
1332 use crate::operations::test_env::{env_lock, EnvGuard};
1333 let _lock = env_lock();
1334 let _guard = EnvGuard::capture(&["HOME"]);
1335
1336 let home = tempfile::TempDir::new().unwrap();
1337 std::env::set_var("HOME", home.path());
1338
1339 let repo = tempfile::TempDir::new().unwrap();
1340 let wt = repo.path().join("wt1");
1341 std::fs::create_dir_all(wt.join(".git")).unwrap();
1342 let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
1343
1344 let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
1347 let proj_dir = home.path().join(".claude").join("projects").join(encoded);
1348 std::fs::create_dir_all(&proj_dir).unwrap();
1349 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1350 let line = serde_json::json!({
1351 "timestamp": now,
1352 "cwd": wt_canon.to_string_lossy(),
1353 });
1354 std::fs::write(
1355 proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
1356 format!("{}\n", line),
1357 )
1358 .unwrap();
1359
1360 let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
1361 assert_ne!(
1362 status, "busy",
1363 "expected non-busy without a live claude process, got busy"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_get_worktree_status_stale() {
1369 use std::path::PathBuf;
1370 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1371 let repo = PathBuf::from("/tmp");
1372 assert_eq!(
1373 get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1374 "stale"
1375 );
1376 }
1377}