Skip to main content

git_worktree_manager/operations/
display.rs

1/// Display and information operations for git-worktree-manager.
2///
3/// Mirrors src/git_worktree_manager/operations/display.py (537 lines).
4use 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
17/// Minimum terminal width for table layout; below this, use compact layout.
18const MIN_TABLE_WIDTH: usize = 100;
19
20/// Determine the status of a worktree.
21pub fn get_worktree_status(path: &Path, _repo: &Path) -> String {
22    if !path.exists() {
23        return "stale".to_string();
24    }
25
26    // Check if cwd is inside this worktree
27    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    // Check for uncommitted changes
36    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
45/// Format age in days to human-readable string.
46pub 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
65/// Compute age string for a path.
66fn 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
82/// Collected worktree data row for display.
83struct WorktreeRow {
84    worktree_id: String,
85    current_branch: String,
86    status: String,
87    age: String,
88    rel_path: String,
89}
90
91/// List all worktrees for the current repository.
92pub 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        // Look up intended branch
113        let intended_branch = lookup_intended_branch(&repo, &current_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    // Summary footer
133    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
167/// Look up the intended branch for a worktree via git config metadata.
168fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
169    // Try direct lookup
170    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    // Search all intended branch metadata
176    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
289/// Show status of current worktree and list all worktrees.
290pub 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
323/// Display worktree hierarchy in a visual tree format.
324pub 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    // Legend
391    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
410/// Display usage analytics for worktrees.
411pub 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    // Overview
470    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    // Age statistics
490    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    // Commit statistics
508    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    // Top by age
526    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    // Top by commits
554    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
580/// Compare two branches.
581pub 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}