Skip to main content

git_worktree_manager/operations/
busy_messages.rs

1//! Render `gw delete` refusal messages for the 3-tier busy model.
2//! Pure string formatting; no I/O. Kept separate from `busy.rs` so the
3//! detection logic can be tested without locale/styling concerns.
4
5use crate::operations::busy::{BusyInfo, BusySource};
6
7fn fmt_age(secs: u64) -> String {
8    if secs < 60 {
9        format!("{}s ago", secs)
10    } else if secs < 3600 {
11        format!(
12            "{} minute{} ago",
13            secs / 60,
14            if secs / 60 == 1 { "" } else { "s" }
15        )
16    } else {
17        format!(
18            "{} hour{} ago",
19            secs / 3600,
20            if secs / 3600 == 1 { "" } else { "s" }
21        )
22    }
23}
24
25fn render_hard_section(out: &mut String, hard: &[BusyInfo]) {
26    for h in hard {
27        match h.source {
28            BusySource::ClaudeSession => {
29                out.push_str("  Active Claude session\n");
30                if let Some(secs) = h.started_secs_ago {
31                    out.push_str(&format!("    last activity: {}\n", fmt_age(secs)));
32                }
33                // cmd carries "claude (session <id>)"
34                if let Some(id_part) = h.cmd.strip_prefix("claude (session ") {
35                    let id = id_part.trim_end_matches(')');
36                    out.push_str(&format!("    session: {}\n", id));
37                }
38            }
39            BusySource::Lockfile => {
40                out.push_str(&format!("  Lockfile holder: PID {} ({})\n", h.pid, h.cmd));
41            }
42            BusySource::ProcessScan => {
43                // Should not appear in hard tier; render defensively.
44                out.push_str(&format!("  PID {}  {}\n", h.pid, h.cmd));
45            }
46        }
47        out.push('\n');
48    }
49}
50
51fn render_soft_list(out: &mut String, soft: &[BusyInfo]) {
52    for s in soft {
53        let tty_label = match s.tty {
54            Some(true) => "(interactive)",
55            Some(false) => "(no tty)",
56            None => "",
57        };
58        let age_label = match s.started_secs_ago {
59            Some(secs) if secs < 90 => format!(" (started {})", fmt_age(secs)),
60            _ => String::new(),
61        };
62        out.push_str(&format!(
63            "    PID {:>6}  {}  {}{}\n",
64            s.pid, s.cmd, tty_label, age_label
65        ));
66    }
67}
68
69/// Render the user-facing refusal text. Empty inputs in both vectors is a
70/// programming error (caller should not have refused) but is rendered as
71/// an empty string for safety.
72pub fn render_refusal(branch_display: &str, hard: &[BusyInfo], soft: &[BusyInfo]) -> String {
73    let mut out = String::new();
74    match (hard.is_empty(), soft.is_empty()) {
75        (true, true) => return out,
76        (true, false) => {
77            out.push_str(&format!(
78                "⚠ Worktree '{}' may be in use:\n\n",
79                branch_display
80            ));
81            out.push_str("  Processes with cwd in this worktree:\n");
82            render_soft_list(&mut out, soft);
83            out.push('\n');
84            out.push_str("  These may malfunction if the worktree is deleted.\n");
85            out.push_str("  Re-run with --force to delete anyway.\n");
86        }
87        (false, true) => {
88            out.push_str(&format!(
89                "✗ Cannot delete worktree '{}' — in use:\n\n",
90                branch_display
91            ));
92            render_hard_section(&mut out, hard);
93            out.push_str("  Use --force to delete anyway.\n");
94        }
95        (false, false) => {
96            out.push_str(&format!(
97                "✗ Cannot delete worktree '{}' — in use:\n\n",
98                branch_display
99            ));
100            render_hard_section(&mut out, hard);
101            out.push_str("  Additional processes with cwd in this worktree:\n");
102            render_soft_list(&mut out, soft);
103            out.push('\n');
104            out.push_str("  Use --force to delete anyway.\n");
105        }
106    }
107    out
108}