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(20, 35) + 2;
212 let br_col = max_br.clamp(20, 35) + 2;
213
214 println!(
215 "{:<wt_col$} {:<br_col$} {:<10} {:<12} PATH",
216 "WORKTREE",
217 "CURRENT BRANCH",
218 "STATUS",
219 "AGE",
220 wt_col = wt_col,
221 br_col = br_col,
222 );
223 println!("{}", "─".repeat(wt_col + br_col + 72));
224
225 for row in rows {
226 let branch_display = if row.worktree_id != row.current_branch {
227 style(format!("{} (⚠️)", row.current_branch))
228 .yellow()
229 .to_string()
230 } else {
231 row.current_branch.clone()
232 };
233
234 let status_styled =
235 cwconsole::status_style(&row.status).apply_to(format!("{:<10}", row.status));
236
237 println!(
238 "{:<wt_col$} {:<br_col$} {} {:<12} {}",
239 row.worktree_id,
240 branch_display,
241 status_styled,
242 row.age,
243 row.rel_path,
244 wt_col = wt_col,
245 br_col = br_col,
246 );
247 }
248}
249
250fn print_worktree_compact(rows: &[WorktreeRow]) {
251 for row in rows {
252 let status_styled = cwconsole::status_style(&row.status).apply_to(&row.status);
253 let age_part = if row.age.is_empty() {
254 String::new()
255 } else {
256 format!(" {}", row.age)
257 };
258
259 println!(
260 " {} {}{}",
261 style(&row.worktree_id).bold(),
262 status_styled,
263 age_part,
264 );
265
266 let mut details = Vec::new();
267 if row.worktree_id != row.current_branch {
268 details.push(format!(
269 "branch: {}",
270 style(format!("{} (⚠️)", row.current_branch)).yellow()
271 ));
272 }
273 details.push(format!("path: {}", row.rel_path));
274 println!(" {}", details.join(" · "));
275 }
276}
277
278pub fn show_status() -> Result<()> {
280 let repo = git::get_repo_root(None)?;
281
282 match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
283 Ok(branch) => {
284 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
285 let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
286 let base = git::get_config(&base_key, Some(&repo));
287 let base_path = git::get_config(&path_key, Some(&repo));
288
289 println!("\n{}", style("Current worktree:").cyan().bold());
290 println!(" Feature: {}", style(&branch).green());
291 println!(
292 " Base: {}",
293 style(base.as_deref().unwrap_or("N/A")).green()
294 );
295 println!(
296 " Base path: {}\n",
297 style(base_path.as_deref().unwrap_or("N/A")).blue()
298 );
299 }
300 Err(_) => {
301 println!(
302 "\n{}\n",
303 style("Current directory is not a feature worktree or is the main repository.")
304 .yellow()
305 );
306 }
307 }
308
309 list_worktrees()
310}
311
312pub fn show_tree() -> Result<()> {
314 let repo = git::get_repo_root(None)?;
315 let cwd = std::env::current_dir().unwrap_or_default();
316
317 let repo_name = repo
318 .file_name()
319 .map(|n| n.to_string_lossy().to_string())
320 .unwrap_or_else(|| "repo".to_string());
321
322 println!(
323 "\n{} (base repository)",
324 style(format!("{}/", repo_name)).cyan().bold()
325 );
326 println!("{}\n", style(repo.display().to_string()).dim());
327
328 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
329
330 if feature_worktrees.is_empty() {
331 println!("{}\n", style(" (no feature worktrees)").dim());
332 return Ok(());
333 }
334
335 let mut sorted = feature_worktrees;
336 sorted.sort_by(|a, b| a.0.cmp(&b.0));
337
338 for (i, (branch_name, path)) in sorted.iter().enumerate() {
339 let is_last = i == sorted.len() - 1;
340 let prefix = if is_last { "└── " } else { "├── " };
341
342 let status = get_worktree_status(path, &repo);
343 let is_current = cwd
344 .to_string_lossy()
345 .starts_with(&path.to_string_lossy().to_string());
346
347 let icon = cwconsole::status_icon(&status);
348 let st = cwconsole::status_style(&status);
349
350 let branch_display = if is_current {
351 st.clone()
352 .bold()
353 .apply_to(format!("★ {}", branch_name))
354 .to_string()
355 } else {
356 st.clone().apply_to(branch_name.as_str()).to_string()
357 };
358
359 println!("{}{} {}", prefix, st.apply_to(icon), branch_display);
360
361 let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
362 format!("../{}", rel.display())
363 } else {
364 path.display().to_string()
365 };
366
367 let continuation = if is_last { " " } else { "│ " };
368 println!("{}{}", continuation, style(&path_display).dim());
369 }
370
371 println!("\n{}", style("Legend:").bold());
373 println!(
374 " {} active (current)",
375 cwconsole::status_style("active").apply_to("●")
376 );
377 println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
378 println!(
379 " {} modified",
380 cwconsole::status_style("modified").apply_to("◉")
381 );
382 println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
383 println!(
384 " {} currently active worktree\n",
385 style("★").green().bold()
386 );
387
388 Ok(())
389}
390
391pub fn show_stats() -> Result<()> {
393 let repo = git::get_repo_root(None)?;
394 let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
395
396 if feature_worktrees.is_empty() {
397 println!("\n{}\n", style("No feature worktrees found").yellow());
398 return Ok(());
399 }
400
401 println!("\n{}\n", style("Worktree Statistics").cyan().bold());
402
403 struct WtData {
404 branch: String,
405 status: String,
406 age_days: f64,
407 commit_count: usize,
408 }
409
410 let mut data: Vec<WtData> = Vec::new();
411
412 for (branch_name, path) in &feature_worktrees {
413 let status = get_worktree_status(path, &repo);
414 let age_days = path_age_days(path).unwrap_or(0.0);
415
416 let commit_count = git::git_command(
417 &["rev-list", "--count", branch_name],
418 Some(path),
419 false,
420 true,
421 )
422 .ok()
423 .and_then(|r| {
424 if r.returncode == 0 {
425 r.stdout.trim().parse::<usize>().ok()
426 } else {
427 None
428 }
429 })
430 .unwrap_or(0);
431
432 data.push(WtData {
433 branch: branch_name.clone(),
434 status,
435 age_days,
436 commit_count,
437 });
438 }
439
440 let mut status_counts: std::collections::HashMap<&str, usize> =
442 std::collections::HashMap::new();
443 for d in &data {
444 *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
445 }
446
447 println!("{}", style("Overview:").bold());
448 println!(" Total worktrees: {}", data.len());
449 println!(
450 " Status: {} clean, {} modified, {} active, {} stale",
451 style(status_counts.get("clean").unwrap_or(&0)).green(),
452 style(status_counts.get("modified").unwrap_or(&0)).yellow(),
453 style(status_counts.get("active").unwrap_or(&0))
454 .green()
455 .bold(),
456 style(status_counts.get("stale").unwrap_or(&0)).red(),
457 );
458 println!();
459
460 let ages: Vec<f64> = data
462 .iter()
463 .filter(|d| d.age_days > 0.0)
464 .map(|d| d.age_days)
465 .collect();
466 if !ages.is_empty() {
467 let avg = ages.iter().sum::<f64>() / ages.len() as f64;
468 let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
469 let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
470
471 println!("{}", style("Age Statistics:").bold());
472 println!(" Average age: {:.1} days", avg);
473 println!(" Oldest: {:.1} days", oldest);
474 println!(" Newest: {:.1} days", newest);
475 println!();
476 }
477
478 let commits: Vec<usize> = data
480 .iter()
481 .filter(|d| d.commit_count > 0)
482 .map(|d| d.commit_count)
483 .collect();
484 if !commits.is_empty() {
485 let total: usize = commits.iter().sum();
486 let avg = total as f64 / commits.len() as f64;
487 let max_c = *commits.iter().max().unwrap_or(&0);
488
489 println!("{}", style("Commit Statistics:").bold());
490 println!(" Total commits across all worktrees: {}", total);
491 println!(" Average commits per worktree: {:.1}", avg);
492 println!(" Most commits in a worktree: {}", max_c);
493 println!();
494 }
495
496 println!("{}", style("Oldest Worktrees:").bold());
498 let mut by_age = data.iter().collect::<Vec<_>>();
499 by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
500 for d in by_age.iter().take(5) {
501 if d.age_days > 0.0 {
502 let icon = cwconsole::status_icon(&d.status);
503 let st = cwconsole::status_style(&d.status);
504 println!(
505 " {} {:<30} {}",
506 st.apply_to(icon),
507 d.branch,
508 format_age(d.age_days),
509 );
510 }
511 }
512 println!();
513
514 println!("{}", style("Most Active Worktrees (by commits):").bold());
516 let mut by_commits = data.iter().collect::<Vec<_>>();
517 by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
518 for d in by_commits.iter().take(5) {
519 if d.commit_count > 0 {
520 let icon = cwconsole::status_icon(&d.status);
521 let st = cwconsole::status_style(&d.status);
522 println!(
523 " {} {:<30} {} commits",
524 st.apply_to(icon),
525 d.branch,
526 d.commit_count,
527 );
528 }
529 }
530 println!();
531
532 Ok(())
533}
534
535pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
537 let repo = git::get_repo_root(None)?;
538
539 if !git::branch_exists(branch1, Some(&repo)) {
540 return Err(crate::error::CwError::InvalidBranch(format!(
541 "Branch '{}' not found",
542 branch1
543 )));
544 }
545 if !git::branch_exists(branch2, Some(&repo)) {
546 return Err(crate::error::CwError::InvalidBranch(format!(
547 "Branch '{}' not found",
548 branch2
549 )));
550 }
551
552 println!("\n{}", style("Comparing branches:").cyan().bold());
553 println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
554
555 if files {
556 let result = git::git_command(
557 &["diff", "--name-status", branch1, branch2],
558 Some(&repo),
559 true,
560 true,
561 )?;
562 println!("{}\n", style("Changed files:").bold());
563 if result.stdout.trim().is_empty() {
564 println!(" {}", style("No differences found").dim());
565 } else {
566 for line in result.stdout.trim().lines() {
567 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
568 if parts.len() == 2 {
569 let (status_char, filename) = (parts[0], parts[1]);
570 let c = status_char.chars().next().unwrap_or('?');
571 let status_name = match c {
572 'M' => "Modified",
573 'A' => "Added",
574 'D' => "Deleted",
575 'R' => "Renamed",
576 'C' => "Copied",
577 _ => "Changed",
578 };
579 let styled_status = match c {
580 'M' => style(status_char).yellow(),
581 'A' => style(status_char).green(),
582 'D' => style(status_char).red(),
583 'R' | 'C' => style(status_char).cyan(),
584 _ => style(status_char),
585 };
586 println!(" {} {} ({})", styled_status, filename, status_name);
587 }
588 }
589 }
590 } else if summary {
591 let result = git::git_command(
592 &["diff", "--stat", branch1, branch2],
593 Some(&repo),
594 true,
595 true,
596 )?;
597 println!("{}\n", style("Diff summary:").bold());
598 if result.stdout.trim().is_empty() {
599 println!(" {}", style("No differences found").dim());
600 } else {
601 println!("{}", result.stdout);
602 }
603 } else {
604 let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
605 if result.stdout.trim().is_empty() {
606 println!("{}\n", style("No differences found").dim());
607 } else {
608 println!("{}", result.stdout);
609 }
610 }
611
612 Ok(())
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn test_format_age_just_now() {
621 assert_eq!(format_age(0.0), "just now");
622 assert_eq!(format_age(0.001), "just now"); }
624
625 #[test]
626 fn test_format_age_hours() {
627 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"); }
631
632 #[test]
633 fn test_format_age_days() {
634 assert_eq!(format_age(1.0), "1d ago");
635 assert_eq!(format_age(1.5), "1d ago");
636 assert_eq!(format_age(6.9), "6d ago");
637 }
638
639 #[test]
640 fn test_format_age_weeks() {
641 assert_eq!(format_age(7.0), "1w ago");
642 assert_eq!(format_age(14.0), "2w ago");
643 assert_eq!(format_age(29.0), "4w ago");
644 }
645
646 #[test]
647 fn test_format_age_months() {
648 assert_eq!(format_age(30.0), "1mo ago");
649 assert_eq!(format_age(60.0), "2mo ago");
650 assert_eq!(format_age(364.0), "12mo ago");
651 }
652
653 #[test]
654 fn test_format_age_years() {
655 assert_eq!(format_age(365.0), "1y ago");
656 assert_eq!(format_age(730.0), "2y ago");
657 }
658
659 #[test]
660 fn test_format_age_boundary_below_one_hour() {
661 assert_eq!(format_age(0.04), "just now"); }
664}