Skip to main content

git_worktree_manager/operations/
display.rs

1/// Display and information operations for git-worktree-manager.
2///
3use 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
15/// Minimum terminal width for table layout; below this, use compact layout.
16const MIN_TABLE_WIDTH: usize = 100;
17
18/// Determine the status of a worktree.
19///
20/// Status priority: stale > busy > active > merged > pr-open > modified > clean
21///
22/// Merge detection strategy:
23/// 1. `gh pr view` (primary) — works with all merge strategies (merge commit,
24///    squash merge, rebase merge) because GitHub tracks PR state independently
25///    of commit SHAs.
26/// 2. `git branch --merged` (fallback) — only works when commit SHAs are
27///    preserved (merge commit strategy). Used when `gh` is not available.
28pub fn get_worktree_status(path: &Path, repo: &Path, branch: Option<&str>) -> String {
29    if !path.exists() {
30        return "stale".to_string();
31    }
32
33    // Busy beats "active": another session (claude, shell, editor) holds this
34    // worktree. The current process and its ancestors are excluded inside
35    // detect_busy so the caller's own shell does not self-report.
36    if !crate::operations::busy::detect_busy(path).is_empty() {
37        return "busy".to_string();
38    }
39
40    // Check if cwd is inside this worktree. Canonicalize both sides so that
41    // symlink skew (e.g. macOS /var vs /private/var) does not miss a match.
42    if let Ok(cwd) = std::env::current_dir() {
43        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
44        let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
45        if cwd_canon.starts_with(&path_canon) {
46            return "active".to_string();
47        }
48    }
49
50    // Check merge/PR status if branch name is available
51    if let Some(branch_name) = branch {
52        let base_branch = {
53            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
54            git::get_config(&key, Some(repo))
55                .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
56        };
57
58        // Primary: GitHub PR status via `gh` CLI
59        // Handles all merge strategies (merge commit, squash, rebase)
60        if let Some(pr_state) = git::get_pr_state(branch_name, Some(repo)) {
61            match pr_state.as_str() {
62                "MERGED" => return "merged".to_string(),
63                "OPEN" => return "pr-open".to_string(),
64                _ => {} // CLOSED or other — fall through
65            }
66        }
67
68        // Fallback: git branch --merged (only works for merge-commit strategy)
69        // Used when `gh` is not installed or no PR was created
70        if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
71            return "merged".to_string();
72        }
73    }
74
75    // Check for uncommitted changes
76    if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
77        if result.returncode == 0 && !result.stdout.trim().is_empty() {
78            return "modified".to_string();
79        }
80    }
81
82    "clean".to_string()
83}
84
85/// Format age in days to human-readable string.
86pub fn format_age(age_days: f64) -> String {
87    if age_days < 1.0 {
88        let hours = (age_days * 24.0) as i64;
89        if hours > 0 {
90            format!("{}h ago", hours)
91        } else {
92            "just now".to_string()
93        }
94    } else if age_days < 7.0 {
95        format!("{}d ago", age_days as i64)
96    } else if age_days < 30.0 {
97        format!("{}w ago", (age_days / 7.0) as i64)
98    } else if age_days < 365.0 {
99        format!("{}mo ago", (age_days / 30.0) as i64)
100    } else {
101        format!("{}y ago", (age_days / 365.0) as i64)
102    }
103}
104
105/// Compute age string for a path.
106fn path_age_str(path: &Path) -> String {
107    if !path.exists() {
108        return String::new();
109    }
110    path_age_days(path).map(format_age).unwrap_or_default()
111}
112
113/// Collected worktree data row for display.
114struct WorktreeRow {
115    worktree_id: String,
116    current_branch: String,
117    status: String,
118    age: String,
119    rel_path: String,
120}
121
122/// List all worktrees for the current repository.
123pub fn list_worktrees() -> Result<()> {
124    let repo = git::get_repo_root(None)?;
125    let worktrees = git::parse_worktrees(&repo)?;
126
127    println!(
128        "\n{}  {}\n",
129        style("Worktrees for repository:").cyan().bold(),
130        repo.display()
131    );
132
133    let mut rows: Vec<WorktreeRow> = Vec::new();
134
135    for (branch, path) in &worktrees {
136        let current_branch = git::normalize_branch_name(branch).to_string();
137        let status = get_worktree_status(path, &repo, Some(&current_branch));
138        let rel_path = pathdiff::diff_paths(path, &repo)
139            .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
140            .unwrap_or_else(|| path.to_string_lossy().to_string());
141        let age = path_age_str(path);
142
143        // Look up intended branch
144        let intended_branch = lookup_intended_branch(&repo, &current_branch, path);
145        let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
146
147        rows.push(WorktreeRow {
148            worktree_id,
149            current_branch,
150            status,
151            age,
152            rel_path,
153        });
154    }
155
156    let term_width = cwconsole::terminal_width();
157    if term_width >= MIN_TABLE_WIDTH {
158        print_worktree_table(&rows);
159    } else {
160        print_worktree_compact(&rows);
161    }
162
163    // Summary footer
164    let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
165    if feature_count > 0 {
166        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
167        for row in &rows {
168            *counts.entry(row.status.as_str()).or_insert(0) += 1;
169        }
170
171        let mut summary_parts = Vec::new();
172        for &status_name in &[
173            "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
174        ] {
175            if let Some(&count) = counts.get(status_name) {
176                if count > 0 {
177                    let styled = cwconsole::status_style(status_name)
178                        .apply_to(format!("{} {}", count, status_name));
179                    summary_parts.push(styled.to_string());
180                }
181            }
182        }
183
184        let summary = if summary_parts.is_empty() {
185            format!("\n{} feature worktree(s)", feature_count)
186        } else {
187            format!(
188                "\n{} feature worktree(s) — {}",
189                feature_count,
190                summary_parts.join(", ")
191            )
192        };
193        println!("{}", summary);
194    }
195
196    println!();
197    Ok(())
198}
199
200/// Look up the intended branch for a worktree via git config metadata.
201fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
202    // Try direct lookup
203    let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
204    if let Some(intended) = git::get_config(&key, Some(repo)) {
205        return Some(intended);
206    }
207
208    // Search all intended branch metadata
209    let result = git::git_command(
210        &[
211            "config",
212            "--local",
213            "--get-regexp",
214            r"^worktree\..*\.intendedBranch",
215        ],
216        Some(repo),
217        false,
218        true,
219    )
220    .ok()?;
221
222    if result.returncode != 0 {
223        return None;
224    }
225
226    let repo_name = repo.file_name()?.to_string_lossy().to_string();
227
228    for line in result.stdout.trim().lines() {
229        let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
230        if parts.len() == 2 {
231            let key_parts: Vec<&str> = parts[0].split('.').collect();
232            if key_parts.len() >= 2 {
233                let branch_from_key = key_parts[1];
234                let expected_path_name =
235                    format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
236                if let Some(name) = path.file_name() {
237                    if name.to_string_lossy() == expected_path_name {
238                        return Some(parts[1].to_string());
239                    }
240                }
241            }
242        }
243    }
244
245    None
246}
247
248fn print_worktree_table(rows: &[WorktreeRow]) {
249    let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
250    let max_br = rows
251        .iter()
252        .map(|r| r.current_branch.len())
253        .max()
254        .unwrap_or(20);
255    let wt_col = max_wt.clamp(12, 35) + 2;
256    let br_col = max_br.clamp(12, 35) + 2;
257
258    println!(
259        "  {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
260        style(" ").dim(),
261        style("WORKTREE").dim(),
262        style("BRANCH").dim(),
263        style("STATUS").dim(),
264        style("AGE").dim(),
265        style("PATH").dim(),
266        wt_col = wt_col,
267        br_col = br_col,
268    );
269    let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
270    println!("  {}", style("─".repeat(line_width)).dim());
271
272    for row in rows {
273        let icon = cwconsole::status_icon(&row.status);
274        let st = cwconsole::status_style(&row.status);
275
276        let branch_display = if row.worktree_id != row.current_branch {
277            style(format!("{} ⚠", row.current_branch))
278                .yellow()
279                .to_string()
280        } else {
281            row.current_branch.clone()
282        };
283
284        let status_styled = st.apply_to(format!("{:<10}", row.status));
285
286        println!(
287            "  {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
288            st.apply_to(icon),
289            style(&row.worktree_id).bold(),
290            branch_display,
291            status_styled,
292            style(&row.age).dim(),
293            style(&row.rel_path).dim(),
294            wt_col = wt_col,
295            br_col = br_col,
296        );
297    }
298}
299
300fn print_worktree_compact(rows: &[WorktreeRow]) {
301    for row in rows {
302        let icon = cwconsole::status_icon(&row.status);
303        let st = cwconsole::status_style(&row.status);
304        let age_part = if row.age.is_empty() {
305            String::new()
306        } else {
307            format!("  {}", style(&row.age).dim())
308        };
309
310        println!(
311            "  {} {}  {}{}",
312            st.apply_to(icon),
313            style(&row.worktree_id).bold(),
314            st.apply_to(&row.status),
315            age_part,
316        );
317
318        let mut details = Vec::new();
319        if row.worktree_id != row.current_branch {
320            details.push(format!(
321                "branch: {}",
322                style(format!("{} ⚠", row.current_branch)).yellow()
323            ));
324        }
325        if !row.rel_path.is_empty() {
326            details.push(format!("{}", style(&row.rel_path).dim()));
327        }
328        if !details.is_empty() {
329            println!("      {}", details.join("  "));
330        }
331    }
332}
333
334/// Show status of current worktree and list all worktrees.
335pub fn show_status() -> Result<()> {
336    let repo = git::get_repo_root(None)?;
337
338    match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
339        Ok(branch) => {
340            let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
341            let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
342            let base = git::get_config(&base_key, Some(&repo));
343            let base_path = git::get_config(&path_key, Some(&repo));
344
345            println!("\n{}", style("Current worktree:").cyan().bold());
346            println!("  Feature:  {}", style(&branch).green());
347            println!(
348                "  Base:     {}",
349                style(base.as_deref().unwrap_or("N/A")).green()
350            );
351            println!(
352                "  Base path: {}\n",
353                style(base_path.as_deref().unwrap_or("N/A")).blue()
354            );
355        }
356        Err(_) => {
357            println!(
358                "\n{}\n",
359                style("Current directory is not a feature worktree or is the main repository.")
360                    .yellow()
361            );
362        }
363    }
364
365    list_worktrees()
366}
367
368/// Display worktree hierarchy in a visual tree format.
369pub fn show_tree() -> Result<()> {
370    let repo = git::get_repo_root(None)?;
371    let cwd = std::env::current_dir().unwrap_or_default();
372
373    let repo_name = repo
374        .file_name()
375        .map(|n| n.to_string_lossy().to_string())
376        .unwrap_or_else(|| "repo".to_string());
377
378    println!(
379        "\n{} (base repository)",
380        style(format!("{}/", repo_name)).cyan().bold()
381    );
382    println!("{}\n", style(repo.display().to_string()).dim());
383
384    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
385
386    if feature_worktrees.is_empty() {
387        println!("{}\n", style("  (no feature worktrees)").dim());
388        return Ok(());
389    }
390
391    let mut sorted = feature_worktrees;
392    sorted.sort_by(|a, b| a.0.cmp(&b.0));
393
394    for (i, (branch_name, path)) in sorted.iter().enumerate() {
395        let is_last = i == sorted.len() - 1;
396        let prefix = if is_last { "└── " } else { "├── " };
397
398        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
399        let is_current = cwd
400            .to_string_lossy()
401            .starts_with(&path.to_string_lossy().to_string());
402
403        let icon = cwconsole::status_icon(&status);
404        let st = cwconsole::status_style(&status);
405
406        let branch_display = if is_current {
407            st.clone()
408                .bold()
409                .apply_to(format!("★ {}", branch_name))
410                .to_string()
411        } else {
412            st.clone().apply_to(branch_name.as_str()).to_string()
413        };
414
415        let age = path_age_str(path);
416        let age_display = if age.is_empty() {
417            String::new()
418        } else {
419            format!("  {}", style(age).dim())
420        };
421
422        println!(
423            "{}{} {}{}",
424            prefix,
425            st.apply_to(icon),
426            branch_display,
427            age_display
428        );
429
430        let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
431            format!("../{}", rel.display())
432        } else {
433            path.display().to_string()
434        };
435
436        let continuation = if is_last { "    " } else { "│   " };
437        println!("{}{}", continuation, style(&path_display).dim());
438    }
439
440    // Legend
441    println!("\n{}", style("Legend:").bold());
442    println!(
443        "  {} active (current)",
444        cwconsole::status_style("active").apply_to("●")
445    );
446    println!("  {} clean", cwconsole::status_style("clean").apply_to("○"));
447    println!(
448        "  {} modified",
449        cwconsole::status_style("modified").apply_to("◉")
450    );
451    println!(
452        "  {} pr-open",
453        cwconsole::status_style("pr-open").apply_to("⬆")
454    );
455    println!(
456        "  {} merged",
457        cwconsole::status_style("merged").apply_to("✓")
458    );
459    println!(
460        "  {} busy (other session)",
461        cwconsole::status_style("busy").apply_to("🔒")
462    );
463    println!("  {} stale", cwconsole::status_style("stale").apply_to("x"));
464    println!(
465        "  {} currently active worktree\n",
466        style("★").green().bold()
467    );
468
469    Ok(())
470}
471
472/// Display usage analytics for worktrees.
473pub fn show_stats() -> Result<()> {
474    let repo = git::get_repo_root(None)?;
475    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
476
477    if feature_worktrees.is_empty() {
478        println!("\n{}\n", style("No feature worktrees found").yellow());
479        return Ok(());
480    }
481
482    println!();
483    println!("  {}", style("Worktree Statistics").cyan().bold());
484    println!("  {}", style("─".repeat(40)).dim());
485    println!();
486
487    struct WtData {
488        branch: String,
489        status: String,
490        age_days: f64,
491        commit_count: usize,
492    }
493
494    let mut data: Vec<WtData> = Vec::new();
495
496    for (branch_name, path) in &feature_worktrees {
497        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
498        let age_days = path_age_days(path).unwrap_or(0.0);
499
500        let commit_count = git::git_command(
501            &["rev-list", "--count", branch_name],
502            Some(path),
503            false,
504            true,
505        )
506        .ok()
507        .and_then(|r| {
508            if r.returncode == 0 {
509                r.stdout.trim().parse::<usize>().ok()
510            } else {
511                None
512            }
513        })
514        .unwrap_or(0);
515
516        data.push(WtData {
517            branch: branch_name.clone(),
518            status,
519            age_days,
520            commit_count,
521        });
522    }
523
524    // Overview
525    let mut status_counts: std::collections::HashMap<&str, usize> =
526        std::collections::HashMap::new();
527    for d in &data {
528        *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
529    }
530
531    println!("  {} {}", style("Total:").bold(), data.len());
532
533    // Status bar visualization
534    let total = data.len();
535    let bar_width = 30;
536    let clean = *status_counts.get("clean").unwrap_or(&0);
537    let modified = *status_counts.get("modified").unwrap_or(&0);
538    let active = *status_counts.get("active").unwrap_or(&0);
539    let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
540    let merged = *status_counts.get("merged").unwrap_or(&0);
541    let busy = *status_counts.get("busy").unwrap_or(&0);
542    let stale = *status_counts.get("stale").unwrap_or(&0);
543
544    let bar_clean = (clean * bar_width) / total.max(1);
545    let bar_modified = (modified * bar_width) / total.max(1);
546    let bar_active = (active * bar_width) / total.max(1);
547    let bar_pr_open = (pr_open * bar_width) / total.max(1);
548    let bar_merged = (merged * bar_width) / total.max(1);
549    let bar_busy = (busy * bar_width) / total.max(1);
550    let bar_stale = (stale * bar_width) / total.max(1);
551    // Fill remaining with clean if rounding left gaps
552    let bar_remainder = bar_width
553        - bar_clean
554        - bar_modified
555        - bar_active
556        - bar_pr_open
557        - bar_merged
558        - bar_busy
559        - bar_stale;
560
561    print!("  ");
562    print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
563    print!("{}", style("█".repeat(bar_modified)).yellow());
564    print!("{}", style("█".repeat(bar_active)).green().bold());
565    print!("{}", style("█".repeat(bar_pr_open)).cyan());
566    print!("{}", style("█".repeat(bar_merged)).magenta());
567    print!("{}", style("█".repeat(bar_busy)).red().bold());
568    print!("{}", style("█".repeat(bar_stale)).red());
569    println!();
570
571    let mut parts = Vec::new();
572    if clean > 0 {
573        parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
574    }
575    if modified > 0 {
576        parts.push(format!(
577            "{}",
578            style(format!("◉ {} modified", modified)).yellow()
579        ));
580    }
581    if active > 0 {
582        parts.push(format!(
583            "{}",
584            style(format!("● {} active", active)).green().bold()
585        ));
586    }
587    if pr_open > 0 {
588        parts.push(format!(
589            "{}",
590            style(format!("⬆ {} pr-open", pr_open)).cyan()
591        ));
592    }
593    if merged > 0 {
594        parts.push(format!(
595            "{}",
596            style(format!("✓ {} merged", merged)).magenta()
597        ));
598    }
599    if busy > 0 {
600        parts.push(format!(
601            "{}",
602            style(format!("🔒 {} busy", busy)).red().bold()
603        ));
604    }
605    if stale > 0 {
606        parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
607    }
608    println!("  {}", parts.join("  "));
609    println!();
610
611    // Age statistics
612    let ages: Vec<f64> = data
613        .iter()
614        .filter(|d| d.age_days > 0.0)
615        .map(|d| d.age_days)
616        .collect();
617    if !ages.is_empty() {
618        let avg = ages.iter().sum::<f64>() / ages.len() as f64;
619        let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
620        let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
621
622        println!("  {} Age", style("◷").dim());
623        println!(
624            "    avg {}  oldest {}  newest {}",
625            style(format!("{:.1}d", avg)).bold(),
626            style(format!("{:.1}d", oldest)).yellow(),
627            style(format!("{:.1}d", newest)).green(),
628        );
629        println!();
630    }
631
632    // Commit statistics
633    let commits: Vec<usize> = data
634        .iter()
635        .filter(|d| d.commit_count > 0)
636        .map(|d| d.commit_count)
637        .collect();
638    if !commits.is_empty() {
639        let total: usize = commits.iter().sum();
640        let avg = total as f64 / commits.len() as f64;
641        let max_c = *commits.iter().max().unwrap_or(&0);
642
643        println!("  {} Commits", style("⟲").dim());
644        println!(
645            "    total {}  avg {:.1}  max {}",
646            style(total).bold(),
647            avg,
648            style(max_c).bold(),
649        );
650        println!();
651    }
652
653    // Top by age
654    println!("  {}", style("Oldest Worktrees").bold());
655    let mut by_age = data.iter().collect::<Vec<_>>();
656    by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
657    let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
658    for d in by_age.iter().take(5) {
659        if d.age_days > 0.0 {
660            let icon = cwconsole::status_icon(&d.status);
661            let st = cwconsole::status_style(&d.status);
662            let bar_len = ((d.age_days / max_age) * 15.0) as usize;
663            println!(
664                "    {} {:<25} {} {}",
665                st.apply_to(icon),
666                d.branch,
667                style("▓".repeat(bar_len.max(1))).dim(),
668                style(format_age(d.age_days)).dim(),
669            );
670        }
671    }
672    println!();
673
674    // Top by commits
675    println!("  {}", style("Most Active (by commits)").bold());
676    let mut by_commits = data.iter().collect::<Vec<_>>();
677    by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
678    let max_commits = by_commits
679        .first()
680        .map(|d| d.commit_count)
681        .unwrap_or(1)
682        .max(1);
683    for d in by_commits.iter().take(5) {
684        if d.commit_count > 0 {
685            let icon = cwconsole::status_icon(&d.status);
686            let st = cwconsole::status_style(&d.status);
687            let bar_len = (d.commit_count * 15) / max_commits;
688            println!(
689                "    {} {:<25} {} {}",
690                st.apply_to(icon),
691                d.branch,
692                style("▓".repeat(bar_len.max(1))).cyan(),
693                style(format!("{} commits", d.commit_count)).dim(),
694            );
695        }
696    }
697    println!();
698
699    Ok(())
700}
701
702/// Compare two branches.
703pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
704    let repo = git::get_repo_root(None)?;
705
706    if !git::branch_exists(branch1, Some(&repo)) {
707        return Err(crate::error::CwError::InvalidBranch(format!(
708            "Branch '{}' not found",
709            branch1
710        )));
711    }
712    if !git::branch_exists(branch2, Some(&repo)) {
713        return Err(crate::error::CwError::InvalidBranch(format!(
714            "Branch '{}' not found",
715            branch2
716        )));
717    }
718
719    println!("\n{}", style("Comparing branches:").cyan().bold());
720    println!("  {} {} {}\n", branch1, style("...").yellow(), branch2);
721
722    if files {
723        let result = git::git_command(
724            &["diff", "--name-status", branch1, branch2],
725            Some(&repo),
726            true,
727            true,
728        )?;
729        println!("{}\n", style("Changed files:").bold());
730        if result.stdout.trim().is_empty() {
731            println!("  {}", style("No differences found").dim());
732        } else {
733            for line in result.stdout.trim().lines() {
734                let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
735                if parts.len() == 2 {
736                    let (status_char, filename) = (parts[0], parts[1]);
737                    let c = status_char.chars().next().unwrap_or('?');
738                    let status_name = match c {
739                        'M' => "Modified",
740                        'A' => "Added",
741                        'D' => "Deleted",
742                        'R' => "Renamed",
743                        'C' => "Copied",
744                        _ => "Changed",
745                    };
746                    let styled_status = match c {
747                        'M' => style(status_char).yellow(),
748                        'A' => style(status_char).green(),
749                        'D' => style(status_char).red(),
750                        'R' | 'C' => style(status_char).cyan(),
751                        _ => style(status_char),
752                    };
753                    println!("  {}  {} ({})", styled_status, filename, status_name);
754                }
755            }
756        }
757    } else if summary {
758        let result = git::git_command(
759            &["diff", "--stat", branch1, branch2],
760            Some(&repo),
761            true,
762            true,
763        )?;
764        println!("{}\n", style("Diff summary:").bold());
765        if result.stdout.trim().is_empty() {
766            println!("  {}", style("No differences found").dim());
767        } else {
768            println!("{}", result.stdout);
769        }
770    } else {
771        let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
772        if result.stdout.trim().is_empty() {
773            println!("{}\n", style("No differences found").dim());
774        } else {
775            println!("{}", result.stdout);
776        }
777    }
778
779    Ok(())
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn test_format_age_just_now() {
788        assert_eq!(format_age(0.0), "just now");
789        assert_eq!(format_age(0.001), "just now"); // ~1.4 minutes
790    }
791
792    #[test]
793    fn test_format_age_hours() {
794        assert_eq!(format_age(1.0 / 24.0), "1h ago"); // exactly 1 hour
795        assert_eq!(format_age(0.5), "12h ago"); // 12 hours
796        assert_eq!(format_age(0.99), "23h ago"); // ~23.7 hours
797    }
798
799    #[test]
800    fn test_format_age_days() {
801        assert_eq!(format_age(1.0), "1d ago");
802        assert_eq!(format_age(1.5), "1d ago");
803        assert_eq!(format_age(6.9), "6d ago");
804    }
805
806    #[test]
807    fn test_format_age_weeks() {
808        assert_eq!(format_age(7.0), "1w ago");
809        assert_eq!(format_age(14.0), "2w ago");
810        assert_eq!(format_age(29.0), "4w ago");
811    }
812
813    #[test]
814    fn test_format_age_months() {
815        assert_eq!(format_age(30.0), "1mo ago");
816        assert_eq!(format_age(60.0), "2mo ago");
817        assert_eq!(format_age(364.0), "12mo ago");
818    }
819
820    #[test]
821    fn test_format_age_years() {
822        assert_eq!(format_age(365.0), "1y ago");
823        assert_eq!(format_age(730.0), "2y ago");
824    }
825
826    #[test]
827    fn test_format_age_boundary_below_one_hour() {
828        // Less than 1 hour (1/24 day ≈ 0.0417)
829        assert_eq!(format_age(0.04), "just now"); // 0.04 * 24 = 0.96h → 0 as i64
830    }
831
832    // Note: this test exercises only the busy signal — repo/worktree
833    // wiring (git::parse_worktrees etc.) is not exercised; the path is
834    // used as a bare directory.
835    #[test]
836    #[cfg(unix)]
837    fn test_get_worktree_status_busy_from_lockfile() {
838        use crate::operations::lockfile::LockEntry;
839        use std::fs;
840        use std::process::{Command, Stdio};
841
842        let tmp = tempfile::TempDir::new().unwrap();
843        let repo = tmp.path();
844        let wt = repo.join("wt1");
845        fs::create_dir_all(wt.join(".git")).unwrap();
846
847        // Spawn a child process: its PID is a descendant (not ancestor) of
848        // the current process, so self_process_tree() will not contain it.
849        // This gives us a live foreign PID to prove the busy signal fires.
850        let mut child = Command::new("sleep")
851            .arg("30")
852            .stdout(Stdio::null())
853            .stderr(Stdio::null())
854            .spawn()
855            .expect("spawn sleep");
856        let foreign_pid: u32 = child.id();
857
858        let entry = LockEntry {
859            version: crate::operations::lockfile::LOCK_VERSION,
860            pid: foreign_pid,
861            started_at: 0,
862            cmd: "claude".to_string(),
863        };
864        fs::write(
865            wt.join(".git").join("gw-session.lock"),
866            serde_json::to_string(&entry).unwrap(),
867        )
868        .unwrap();
869
870        let status = get_worktree_status(&wt, repo, Some("wt1"));
871
872        // Clean up child before asserting, so a failed assert still reaps it.
873        let _ = child.kill();
874        let _ = child.wait();
875
876        assert_eq!(status, "busy");
877    }
878
879    #[test]
880    fn test_get_worktree_status_stale() {
881        use std::path::PathBuf;
882        let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
883        let repo = PathBuf::from("/tmp");
884        assert_eq!(get_worktree_status(&non_existent, &repo, None), "stale");
885    }
886}