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