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 busy-status block (header + body) for read-only callers
70/// like `gw status` / `gw list`. Same body sections as `render_refusal`
71/// (Active Claude session / Lockfile holder / cwd processes) but with a
72/// neutral header and no `--force` guidance. Returns an empty string
73/// when both vectors are empty.
74pub fn render_busy_block(branch_display: &str, hard: &[BusyInfo], soft: &[BusyInfo]) -> String {
75    let mut out = String::new();
76    if hard.is_empty() && soft.is_empty() {
77        return out;
78    }
79    out.push_str(&format!(
80        "⚠ Worktree '{}' may be in use:\n\n",
81        branch_display
82    ));
83    if !hard.is_empty() {
84        render_hard_section(&mut out, hard);
85    }
86    if !soft.is_empty() {
87        out.push_str("  Processes with cwd in this worktree:\n");
88        render_soft_list(&mut out, soft);
89    }
90    out
91}
92
93/// Render the user-facing refusal text for `gw delete`. Empty inputs in
94/// both vectors is a programming error (caller should not have refused)
95/// but is rendered as an empty string for safety.
96pub fn render_refusal(branch_display: &str, hard: &[BusyInfo], soft: &[BusyInfo]) -> String {
97    let mut out = String::new();
98    match (hard.is_empty(), soft.is_empty()) {
99        (true, true) => return out,
100        (true, false) => {
101            out.push_str(&format!(
102                "⚠ Worktree '{}' may be in use:\n\n",
103                branch_display
104            ));
105            out.push_str("  Processes with cwd in this worktree:\n");
106            render_soft_list(&mut out, soft);
107            out.push('\n');
108            out.push_str("  These may malfunction if the worktree is deleted.\n");
109            out.push_str("  Re-run with --force to delete anyway.\n");
110        }
111        (false, true) => {
112            out.push_str(&format!(
113                "✗ Cannot delete worktree '{}' — in use:\n\n",
114                branch_display
115            ));
116            render_hard_section(&mut out, hard);
117            out.push_str("  Use --force to delete anyway.\n");
118        }
119        (false, false) => {
120            out.push_str(&format!(
121                "✗ Cannot delete worktree '{}' — in use:\n\n",
122                branch_display
123            ));
124            render_hard_section(&mut out, hard);
125            out.push_str("  Additional processes with cwd in this worktree:\n");
126            render_soft_list(&mut out, soft);
127            out.push('\n');
128            out.push_str("  Use --force to delete anyway.\n");
129        }
130    }
131    out
132}