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.
19pub fn get_worktree_status(path: &Path, _repo: &Path) -> String {
20    if !path.exists() {
21        return "stale".to_string();
22    }
23
24    // Check if cwd is inside this worktree
25    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    // Check for uncommitted changes
34    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
43/// Format age in days to human-readable string.
44pub 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
63/// Compute age string for a path.
64fn 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
71/// Collected worktree data row for display.
72struct WorktreeRow {
73    worktree_id: String,
74    current_branch: String,
75    status: String,
76    age: String,
77    rel_path: String,
78}
79
80/// List all worktrees for the current repository.
81pub 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        // Look up intended branch
102        let intended_branch = lookup_intended_branch(&repo, &current_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    // Summary footer
122    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
156/// Look up the intended branch for a worktree via git config metadata.
157fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
158    // Try direct lookup
159    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    // Search all intended branch metadata
165    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
278/// Show status of current worktree and list all worktrees.
279pub 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
312/// Display worktree hierarchy in a visual tree format.
313pub 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    // Legend
372    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
391/// Display usage analytics for worktrees.
392pub 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    // Overview
441    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    // Age statistics
461    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    // Commit statistics
479    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    // Top by age
497    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    // Top by commits
515    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
535/// Compare two branches.
536pub 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"); // ~1.4 minutes
623    }
624
625    #[test]
626    fn test_format_age_hours() {
627        assert_eq!(format_age(1.0 / 24.0), "1h ago"); // exactly 1 hour
628        assert_eq!(format_age(0.5), "12h ago"); // 12 hours
629        assert_eq!(format_age(0.99), "23h ago"); // ~23.7 hours
630    }
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        // Less than 1 hour (1/24 day ≈ 0.0417)
662        assert_eq!(format_age(0.04), "just now"); // 0.04 * 24 = 0.96h → 0 as i64
663    }
664}