1use std::path::Path;
4
5use console::style;
6
7use crate::console as cwconsole;
8use crate::constants::{
9 format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
10 CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
11};
12use crate::error::Result;
13use crate::git;
14
15const MIN_TABLE_WIDTH: usize = 100;
17
18pub fn get_worktree_status(path: &Path, _repo: &Path) -> String {
20 if !path.exists() {
21 return "stale".to_string();
22 }
23
24 if let Ok(cwd) = std::env::current_dir() {
26 let cwd_str = cwd.to_string_lossy().to_string();
27 let path_str = path.to_string_lossy().to_string();
28 if cwd_str.starts_with(&path_str) {
29 return "active".to_string();
30 }
31 }
32
33 if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
35 if result.returncode == 0 && !result.stdout.trim().is_empty() {
36 return "modified".to_string();
37 }
38 }
39
40 "clean".to_string()
41}
42
43pub fn format_age(age_days: f64) -> String {
45 if age_days < 1.0 {
46 let hours = (age_days * 24.0) as i64;
47 if hours > 0 {
48 format!("{}h ago", hours)
49 } else {
50 "just now".to_string()
51 }
52 } else if age_days < 7.0 {
53 format!("{}d ago", age_days as i64)
54 } else if age_days < 30.0 {
55 format!("{}w ago", (age_days / 7.0) as i64)
56 } else if age_days < 365.0 {
57 format!("{}mo ago", (age_days / 30.0) as i64)
58 } else {
59 format!("{}y ago", (age_days / 365.0) as i64)
60 }
61}
62
63fn path_age_str(path: &Path) -> String {
65 if !path.exists() {
66 return String::new();
67 }
68 path_age_days(path).map(format_age).unwrap_or_default()
69}
70
71struct WorktreeRow {
73 worktree_id: String,
74 current_branch: String,
75 status: String,
76 age: String,
77 rel_path: String,
78}
79
80pub fn list_worktrees() -> Result<()> {
82 let repo = git::get_repo_root(None)?;
83 let worktrees = git::parse_worktrees(&repo)?;
84
85 println!(
86 "\n{} {}\n",
87 style("Worktrees for repository:").cyan().bold(),
88 repo.display()
89 );
90
91 let mut rows: Vec<WorktreeRow> = Vec::new();
92
93 for (branch, path) in &worktrees {
94 let current_branch = git::normalize_branch_name(branch).to_string();
95 let status = get_worktree_status(path, &repo);
96 let rel_path = pathdiff::diff_paths(path, &repo)
97 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
98 .unwrap_or_else(|| path.to_string_lossy().to_string());
99 let age = path_age_str(path);
100
101 let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
103 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
104
105 rows.push(WorktreeRow {
106 worktree_id,
107 current_branch,
108 status,
109 age,
110 rel_path,
111 });
112 }
113
114 let term_width = cwconsole::terminal_width();
115 if term_width >= MIN_TABLE_WIDTH {
116 print_worktree_table(&rows);
117 } else {
118 print_worktree_compact(&rows);
119 }
120
121 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
123 if feature_count > 0 {
124 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
125 for row in &rows {
126 *counts.entry(row.status.as_str()).or_insert(0) += 1;
127 }
128
129 let mut summary_parts = Vec::new();
130 for &status_name in &["clean", "modified", "active", "stale"] {
131 if let Some(&count) = counts.get(status_name) {
132 if count > 0 {
133 let styled = cwconsole::status_style(status_name)
134 .apply_to(format!("{} {}", count, status_name));
135 summary_parts.push(styled.to_string());
136 }
137 }
138 }
139
140 let summary = if summary_parts.is_empty() {
141 format!("\n{} feature worktree(s)", feature_count)
142 } else {
143 format!(
144 "\n{} feature worktree(s) — {}",
145 feature_count,
146 summary_parts.join(", ")
147 )
148 };
149 println!("{}", summary);
150 }
151
152 println!();
153 Ok(())
154}
155
156fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
158 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
160 if let Some(intended) = git::get_config(&key, Some(repo)) {
161 return Some(intended);
162 }
163
164 let result = git::git_command(
166 &[
167 "config",
168 "--local",
169 "--get-regexp",
170 r"^worktree\..*\.intendedBranch",
171 ],
172 Some(repo),
173 false,
174 true,
175 )
176 .ok()?;
177
178 if result.returncode != 0 {
179 return None;
180 }
181
182 let repo_name = repo.file_name()?.to_string_lossy().to_string();
183
184 for line in result.stdout.trim().lines() {
185 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
186 if parts.len() == 2 {
187 let key_parts: Vec<&str> = parts[0].split('.').collect();
188 if key_parts.len() >= 2 {
189 let branch_from_key = key_parts[1];
190 let expected_path_name =
191 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
192 if let Some(name) = path.file_name() {
193 if name.to_string_lossy() == expected_path_name {
194 return Some(parts[1].to_string());
195 }
196 }
197 }
198 }
199 }
200
201 None
202}
203
204fn print_worktree_table(rows: &[WorktreeRow]) {
205 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
206 let max_br = rows
207 .iter()
208 .map(|r| r.current_branch.len())
209 .max()
210 .unwrap_or(20);
211 let wt_col = max_wt.clamp(12, 35) + 2;
212 let br_col = max_br.clamp(12, 35) + 2;
213
214 println!(
215 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
216 style(" ").dim(),
217 style("WORKTREE").dim(),
218 style("BRANCH").dim(),
219 style("STATUS").dim(),
220 style("AGE").dim(),
221 style("PATH").dim(),
222 wt_col = wt_col,
223 br_col = br_col,
224 );
225 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
226 println!(" {}", style("─".repeat(line_width)).dim());
227
228 for row in rows {
229 let icon = cwconsole::status_icon(&row.status);
230 let st = cwconsole::status_style(&row.status);
231
232 let branch_display = if row.worktree_id != row.current_branch {
233 style(format!("{} ⚠", row.current_branch))
234 .yellow()
235 .to_string()
236 } else {
237 row.current_branch.clone()
238 };
239
240 let status_styled = st.apply_to(format!("{:<10}", row.status));
241
242 println!(
243 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
244 st.apply_to(icon),
245 style(&row.worktree_id).bold(),
246 branch_display,
247 status_styled,
248 style(&row.age).dim(),
249 style(&row.rel_path).dim(),
250 wt_col = wt_col,
251 br_col = br_col,
252 );
253 }
254}
255
256fn print_worktree_compact(rows: &[WorktreeRow]) {
257 for row in rows {
258 let icon = cwconsole::status_icon(&row.status);
259 let st = cwconsole::status_style(&row.status);
260 let age_part = if row.age.is_empty() {
261 String::new()
262 } else {
263 format!(" {}", style(&row.age).dim())
264 };
265
266 println!(
267 " {} {} {}{}",
268 st.apply_to(icon),
269 style(&row.worktree_id).bold(),
270 st.apply_to(&row.status),
271 age_part,
272 );
273
274 let mut details = Vec::new();
275 if row.worktree_id != row.current_branch {
276 details.push(format!(
277 "branch: {}",
278 style(format!("{} ⚠", row.current_branch)).yellow()
279 ));
280 }
281 if !row.rel_path.is_empty() {
282 details.push(format!("{}", style(&row.rel_path).dim()));
283 }
284 if !details.is_empty() {
285 println!(" {}", details.join(" "));
286 }
287 }
288}
289
290pub fn show_status() -> Result<()> {
292 let repo = git::get_repo_root(None)?;
293
294 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
295 Ok(branch) => {
296 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
297 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
298 let base = git::get_config(&base_key, Some(&repo));
299 let base_path = git::get_config(&path_key, Some(&repo));
300
301 println!("\n{}", style("Current worktree:").cyan().bold());
302 println!(" Feature: {}", style(&branch).green());
303 println!(
304 " Base: {}",
305 style(base.as_deref().unwrap_or("N/A")).green()
306 );
307 println!(
308 " Base path: {}\n",
309 style(base_path.as_deref().unwrap_or("N/A")).blue()
310 );
311 }
312 Err(_) => {
313 println!(
314 "\n{}\n",
315 style("Current directory is not a feature worktree or is the main repository.")
316 .yellow()
317 );
318 }
319 }
320
321 list_worktrees()
322}
323
324pub fn show_tree() -> Result<()> {
326 let repo = git::get_repo_root(None)?;
327 let cwd = std::env::current_dir().unwrap_or_default();
328
329 let repo_name = repo
330 .file_name()
331 .map(|n| n.to_string_lossy().to_string())
332 .unwrap_or_else(|| "repo".to_string());
333
334 println!(
335 "\n{} (base repository)",
336 style(format!("{}/", repo_name)).cyan().bold()
337 );
338 println!("{}\n", style(repo.display().to_string()).dim());
339
340 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
341
342 if feature_worktrees.is_empty() {
343 println!("{}\n", style(" (no feature worktrees)").dim());
344 return Ok(());
345 }
346
347 let mut sorted = feature_worktrees;
348 sorted.sort_by(|a, b| a.0.cmp(&b.0));
349
350 for (i, (branch_name, path)) in sorted.iter().enumerate() {
351 let is_last = i == sorted.len() - 1;
352 let prefix = if is_last { "└── " } else { "├── " };
353
354 let status = get_worktree_status(path, &repo);
355 let is_current = cwd
356 .to_string_lossy()
357 .starts_with(&path.to_string_lossy().to_string());
358
359 let icon = cwconsole::status_icon(&status);
360 let st = cwconsole::status_style(&status);
361
362 let branch_display = if is_current {
363 st.clone()
364 .bold()
365 .apply_to(format!("★ {}", branch_name))
366 .to_string()
367 } else {
368 st.clone().apply_to(branch_name.as_str()).to_string()
369 };
370
371 let age = path_age_str(path);
372 let age_display = if age.is_empty() {
373 String::new()
374 } else {
375 format!(" {}", style(age).dim())
376 };
377
378 println!(
379 "{}{} {}{}",
380 prefix,
381 st.apply_to(icon),
382 branch_display,
383 age_display
384 );
385
386 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
387 format!("../{}", rel.display())
388 } else {
389 path.display().to_string()
390 };
391
392 let continuation = if is_last { " " } else { "│ " };
393 println!("{}{}", continuation, style(&path_display).dim());
394 }
395
396 println!("\n{}", style("Legend:").bold());
398 println!(
399 " {} active (current)",
400 cwconsole::status_style("active").apply_to("●")
401 );
402 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
403 println!(
404 " {} modified",
405 cwconsole::status_style("modified").apply_to("◉")
406 );
407 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
408 println!(
409 " {} currently active worktree\n",
410 style("★").green().bold()
411 );
412
413 Ok(())
414}
415
416pub fn show_stats() -> Result<()> {
418 let repo = git::get_repo_root(None)?;
419 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
420
421 if feature_worktrees.is_empty() {
422 println!("\n{}\n", style("No feature worktrees found").yellow());
423 return Ok(());
424 }
425
426 println!();
427 println!(" {}", style("Worktree Statistics").cyan().bold());
428 println!(" {}", style("─".repeat(40)).dim());
429 println!();
430
431 struct WtData {
432 branch: String,
433 status: String,
434 age_days: f64,
435 commit_count: usize,
436 }
437
438 let mut data: Vec<WtData> = Vec::new();
439
440 for (branch_name, path) in &feature_worktrees {
441 let status = get_worktree_status(path, &repo);
442 let age_days = path_age_days(path).unwrap_or(0.0);
443
444 let commit_count = git::git_command(
445 &["rev-list", "--count", branch_name],
446 Some(path),
447 false,
448 true,
449 )
450 .ok()
451 .and_then(|r| {
452 if r.returncode == 0 {
453 r.stdout.trim().parse::<usize>().ok()
454 } else {
455 None
456 }
457 })
458 .unwrap_or(0);
459
460 data.push(WtData {
461 branch: branch_name.clone(),
462 status,
463 age_days,
464 commit_count,
465 });
466 }
467
468 let mut status_counts: std::collections::HashMap<&str, usize> =
470 std::collections::HashMap::new();
471 for d in &data {
472 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
473 }
474
475 println!(" {} {}", style("Total:").bold(), data.len());
476
477 let total = data.len();
479 let bar_width = 30;
480 let clean = *status_counts.get("clean").unwrap_or(&0);
481 let modified = *status_counts.get("modified").unwrap_or(&0);
482 let active = *status_counts.get("active").unwrap_or(&0);
483 let stale = *status_counts.get("stale").unwrap_or(&0);
484
485 let bar_clean = (clean * bar_width) / total.max(1);
486 let bar_modified = (modified * bar_width) / total.max(1);
487 let bar_active = (active * bar_width) / total.max(1);
488 let bar_stale = (stale * bar_width) / total.max(1);
489 let bar_remainder = bar_width - bar_clean - bar_modified - bar_active - bar_stale;
491
492 print!(" ");
493 print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
494 print!("{}", style("█".repeat(bar_modified)).yellow());
495 print!("{}", style("█".repeat(bar_active)).green().bold());
496 print!("{}", style("█".repeat(bar_stale)).red());
497 println!();
498
499 let mut parts = Vec::new();
500 if clean > 0 {
501 parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
502 }
503 if modified > 0 {
504 parts.push(format!(
505 "{}",
506 style(format!("◉ {} modified", modified)).yellow()
507 ));
508 }
509 if active > 0 {
510 parts.push(format!(
511 "{}",
512 style(format!("● {} active", active)).green().bold()
513 ));
514 }
515 if stale > 0 {
516 parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
517 }
518 println!(" {}", parts.join(" "));
519 println!();
520
521 let ages: Vec<f64> = data
523 .iter()
524 .filter(|d| d.age_days > 0.0)
525 .map(|d| d.age_days)
526 .collect();
527 if !ages.is_empty() {
528 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
529 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
530 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
531
532 println!(" {} Age", style("◷").dim());
533 println!(
534 " avg {} oldest {} newest {}",
535 style(format!("{:.1}d", avg)).bold(),
536 style(format!("{:.1}d", oldest)).yellow(),
537 style(format!("{:.1}d", newest)).green(),
538 );
539 println!();
540 }
541
542 let commits: Vec<usize> = data
544 .iter()
545 .filter(|d| d.commit_count > 0)
546 .map(|d| d.commit_count)
547 .collect();
548 if !commits.is_empty() {
549 let total: usize = commits.iter().sum();
550 let avg = total as f64 / commits.len() as f64;
551 let max_c = *commits.iter().max().unwrap_or(&0);
552
553 println!(" {} Commits", style("⟲").dim());
554 println!(
555 " total {} avg {:.1} max {}",
556 style(total).bold(),
557 avg,
558 style(max_c).bold(),
559 );
560 println!();
561 }
562
563 println!(" {}", style("Oldest Worktrees").bold());
565 let mut by_age = data.iter().collect::<Vec<_>>();
566 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
567 let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
568 for d in by_age.iter().take(5) {
569 if d.age_days > 0.0 {
570 let icon = cwconsole::status_icon(&d.status);
571 let st = cwconsole::status_style(&d.status);
572 let bar_len = ((d.age_days / max_age) * 15.0) as usize;
573 println!(
574 " {} {:<25} {} {}",
575 st.apply_to(icon),
576 d.branch,
577 style("▓".repeat(bar_len.max(1))).dim(),
578 style(format_age(d.age_days)).dim(),
579 );
580 }
581 }
582 println!();
583
584 println!(" {}", style("Most Active (by commits)").bold());
586 let mut by_commits = data.iter().collect::<Vec<_>>();
587 by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
588 let max_commits = by_commits
589 .first()
590 .map(|d| d.commit_count)
591 .unwrap_or(1)
592 .max(1);
593 for d in by_commits.iter().take(5) {
594 if d.commit_count > 0 {
595 let icon = cwconsole::status_icon(&d.status);
596 let st = cwconsole::status_style(&d.status);
597 let bar_len = (d.commit_count * 15) / max_commits;
598 println!(
599 " {} {:<25} {} {}",
600 st.apply_to(icon),
601 d.branch,
602 style("▓".repeat(bar_len.max(1))).cyan(),
603 style(format!("{} commits", d.commit_count)).dim(),
604 );
605 }
606 }
607 println!();
608
609 Ok(())
610}
611
612pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
614 let repo = git::get_repo_root(None)?;
615
616 if !git::branch_exists(branch1, Some(&repo)) {
617 return Err(crate::error::CwError::InvalidBranch(format!(
618 "Branch '{}' not found",
619 branch1
620 )));
621 }
622 if !git::branch_exists(branch2, Some(&repo)) {
623 return Err(crate::error::CwError::InvalidBranch(format!(
624 "Branch '{}' not found",
625 branch2
626 )));
627 }
628
629 println!("\n{}", style("Comparing branches:").cyan().bold());
630 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
631
632 if files {
633 let result = git::git_command(
634 &["diff", "--name-status", branch1, branch2],
635 Some(&repo),
636 true,
637 true,
638 )?;
639 println!("{}\n", style("Changed files:").bold());
640 if result.stdout.trim().is_empty() {
641 println!(" {}", style("No differences found").dim());
642 } else {
643 for line in result.stdout.trim().lines() {
644 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
645 if parts.len() == 2 {
646 let (status_char, filename) = (parts[0], parts[1]);
647 let c = status_char.chars().next().unwrap_or('?');
648 let status_name = match c {
649 'M' => "Modified",
650 'A' => "Added",
651 'D' => "Deleted",
652 'R' => "Renamed",
653 'C' => "Copied",
654 _ => "Changed",
655 };
656 let styled_status = match c {
657 'M' => style(status_char).yellow(),
658 'A' => style(status_char).green(),
659 'D' => style(status_char).red(),
660 'R' | 'C' => style(status_char).cyan(),
661 _ => style(status_char),
662 };
663 println!(" {} {} ({})", styled_status, filename, status_name);
664 }
665 }
666 }
667 } else if summary {
668 let result = git::git_command(
669 &["diff", "--stat", branch1, branch2],
670 Some(&repo),
671 true,
672 true,
673 )?;
674 println!("{}\n", style("Diff summary:").bold());
675 if result.stdout.trim().is_empty() {
676 println!(" {}", style("No differences found").dim());
677 } else {
678 println!("{}", result.stdout);
679 }
680 } else {
681 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
682 if result.stdout.trim().is_empty() {
683 println!("{}\n", style("No differences found").dim());
684 } else {
685 println!("{}", result.stdout);
686 }
687 }
688
689 Ok(())
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn test_format_age_just_now() {
698 assert_eq!(format_age(0.0), "just now");
699 assert_eq!(format_age(0.001), "just now"); }
701
702 #[test]
703 fn test_format_age_hours() {
704 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"); }
708
709 #[test]
710 fn test_format_age_days() {
711 assert_eq!(format_age(1.0), "1d ago");
712 assert_eq!(format_age(1.5), "1d ago");
713 assert_eq!(format_age(6.9), "6d ago");
714 }
715
716 #[test]
717 fn test_format_age_weeks() {
718 assert_eq!(format_age(7.0), "1w ago");
719 assert_eq!(format_age(14.0), "2w ago");
720 assert_eq!(format_age(29.0), "4w ago");
721 }
722
723 #[test]
724 fn test_format_age_months() {
725 assert_eq!(format_age(30.0), "1mo ago");
726 assert_eq!(format_age(60.0), "2mo ago");
727 assert_eq!(format_age(364.0), "12mo ago");
728 }
729
730 #[test]
731 fn test_format_age_years() {
732 assert_eq!(format_age(365.0), "1y ago");
733 assert_eq!(format_age(730.0), "2y ago");
734 }
735
736 #[test]
737 fn test_format_age_boundary_below_one_hour() {
738 assert_eq!(format_age(0.04), "just now"); }
741}