1use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Result};
13use chrono::Utc;
14use clap::{Parser, Subcommand};
15use serde_json::Value;
16
17use crate::cli::format::TableOrJson;
18use crate::daemon::client::DaemonClient;
19use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
20use crate::daemon::server;
21
22const SERVICE: &str = "worktrees";
24
25#[derive(Parser)]
28pub struct WorktreesCommand {
29 #[command(subcommand)]
31 pub command: WorktreesSubcommands,
32}
33
34#[derive(Subcommand)]
36pub enum WorktreesSubcommands {
37 List(ListCommand),
39}
40
41impl WorktreesCommand {
42 pub async fn execute(self) -> Result<()> {
44 match self.command {
45 WorktreesSubcommands::List(cmd) => cmd.execute().await,
46 }
47 }
48}
49
50#[derive(Parser)]
52pub struct ListCommand {
53 #[arg(long, value_name = "PATH")]
55 pub socket: Option<PathBuf>,
56 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
58 pub output: TableOrJson,
59 #[arg(long, hide = true)]
61 pub json: bool,
62}
63
64impl ListCommand {
65 pub async fn execute(mut self) -> Result<()> {
67 if self.json {
68 eprintln!("warning: --json is deprecated; use -o/--output json instead");
69 self.output = TableOrJson::Json;
70 }
71 let socket = server::resolve_socket(self.socket)?;
72 let result = call(&socket, "list", Value::Null).await?;
73 match self.output {
74 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
75 TableOrJson::Table => println!("{}", render_windows(&result)),
76 }
77 Ok(())
78 }
79}
80
81async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
84 let reply = DaemonClient::new(socket)
85 .request(DaemonEnvelope::service(SERVICE, op, payload))
86 .await?;
87 reply_payload(reply)
88}
89
90fn reply_payload(reply: DaemonReply) -> Result<Value> {
93 if reply.ok {
94 Ok(reply.payload)
95 } else {
96 bail!(
97 "daemon returned an error: {}",
98 reply.error.as_deref().unwrap_or("unknown error")
99 )
100 }
101}
102
103fn render_windows(result: &Value) -> String {
108 let windows = result
109 .get("windows")
110 .and_then(Value::as_array)
111 .map(Vec::as_slice)
112 .unwrap_or_default();
113 if windows.is_empty() {
114 return "No open windows.".to_string();
115 }
116 let mut out = format!(
117 "{:<22} {:<24} {:<9} {:<40} {:>5}",
118 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
119 );
120 for window in windows {
121 let repo = sanitize(repo_name(window));
122 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
123 let sync = sync_summary(window);
124 let folder_disp = folder_summary(window);
125 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
126 out.push_str(&format!(
127 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
128 ));
129 }
130 out
131}
132
133fn repo_name(window: &Value) -> &str {
137 window
138 .get("main_repo")
139 .and_then(Value::as_str)
140 .or_else(|| window.get("repo").and_then(Value::as_str))
141 .unwrap_or("-")
142}
143
144fn sync_summary(window: &Value) -> String {
148 let ahead = window.get("ahead").and_then(Value::as_u64);
149 let behind = window.get("behind").and_then(Value::as_u64);
150 match (ahead, behind) {
151 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
152 _ => "-".to_string(),
153 }
154}
155
156fn folder_summary(window: &Value) -> String {
159 let folders = window
160 .get("folders")
161 .and_then(Value::as_array)
162 .map(Vec::as_slice)
163 .unwrap_or_default();
164 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
165 let extra = folders.len().saturating_sub(1);
166 if extra > 0 {
167 format!("{first} (+{extra})")
168 } else {
169 first
170 }
171}
172
173fn sanitize(s: &str) -> String {
177 s.chars().filter(|c| !c.is_control()).collect()
178}
179
180fn age_secs(ts: Option<&str>) -> i64 {
182 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
183 .map_or(0, |t| {
184 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
185 })
186}
187
188#[cfg(test)]
189#[allow(clippy::unwrap_used, clippy::expect_used)]
190mod tests {
191 use super::*;
192 use serde_json::json;
193
194 #[derive(Parser)]
196 struct Wrapper {
197 #[command(subcommand)]
198 cmd: WorktreesSubcommands,
199 }
200
201 fn parse(args: &[&str]) -> WorktreesSubcommands {
202 let mut full = vec!["omni-dev"];
203 full.extend_from_slice(args);
204 Wrapper::try_parse_from(full).unwrap().cmd
205 }
206
207 #[test]
208 fn list_parses_flags_and_defaults() {
209 let WorktreesSubcommands::List(cmd) = parse(&["list"]);
210 assert_eq!(cmd.output, TableOrJson::Table);
211 assert!(!cmd.json);
212 assert!(cmd.socket.is_none());
213
214 let WorktreesSubcommands::List(cmd) =
215 parse(&["list", "-o", "json", "--socket", "/tmp/d.sock"]);
216 assert_eq!(cmd.output, TableOrJson::Json);
217 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
218 }
219
220 #[test]
221 fn list_deprecated_json_flag_still_parses() {
222 let WorktreesSubcommands::List(cmd) = parse(&["list", "--json"]);
224 assert!(cmd.json);
225 assert_eq!(cmd.output, TableOrJson::Table);
226 }
227
228 #[test]
229 fn render_windows_handles_empty_replies() {
230 assert_eq!(
231 render_windows(&json!({ "windows": [] })),
232 "No open windows."
233 );
234 assert_eq!(render_windows(&json!({})), "No open windows.");
235 }
236
237 #[test]
238 fn render_windows_renders_rows() {
239 let result = json!({ "windows": [{
240 "key": "w1",
241 "repo": "omni-dev",
242 "branch": "issue-1011",
243 "ahead": 2,
244 "behind": 1,
245 "folders": ["/home/me/omni-dev", "/home/me/docs"],
246 "last_seen": "2000-01-01T00:00:00Z",
247 }]});
248 let table = render_windows(&result);
249 assert!(table.contains("omni-dev"), "{table}");
250 assert!(table.contains("issue-1011"), "{table}");
252 assert!(table.contains("+2 -1"), "{table}");
253 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
255 assert_eq!(table.lines().count(), 2, "{table}");
257 }
258
259 #[test]
260 fn render_windows_prefers_main_repo_over_companion_repo() {
261 let result = json!({ "windows": [{
265 "key": "w1",
266 "repo": "issue-1250",
267 "main_repo": "omni-dev",
268 "branch": "issue-1250",
269 "folders": ["/home/me/worktrees/issue-1250"],
270 "last_seen": "2000-01-01T00:00:00Z",
271 }]});
272 let table = render_windows(&result);
273 assert!(table.contains("omni-dev"), "{table}");
274 let data_row = table.lines().nth(1).unwrap();
277 assert!(data_row.starts_with("omni-dev"), "{data_row}");
278 }
279
280 #[test]
281 fn repo_name_falls_back_to_companion_repo_then_dash() {
282 assert_eq!(
283 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
284 "omni-dev"
285 );
286 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
287 assert_eq!(repo_name(&json!({})), "-");
288 }
289
290 #[test]
291 fn render_windows_strips_control_bytes() {
292 let result = json!({ "windows": [{
295 "key": "w1",
296 "repo": "evil\x1b[31mrepo",
297 "branch": "br\ranch\x07\u{9b}2J",
298 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
299 "last_seen": "2000-01-01T00:00:00Z",
300 }]});
301 let table = render_windows(&result);
302 assert!(
303 !table.contains(|c: char| c.is_control() && c != '\n'),
304 "{table:?}"
305 );
306 assert!(table.contains("evil[31mrepo"), "{table:?}");
308 assert!(table.contains("branch2J"), "{table:?}");
309 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
310 assert_eq!(table.lines().count(), 2, "{table:?}");
312 }
313
314 #[test]
315 fn sync_summary_formats_or_dashes() {
316 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
317 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
318 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
320 assert_eq!(sync_summary(&json!({})), "-");
321 }
322
323 #[test]
324 fn folder_summary_strips_control_bytes() {
325 assert_eq!(
326 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
327 "/a[2J/b"
328 );
329 }
330
331 #[test]
332 fn folder_summary_counts_extra_folders() {
333 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
334 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
335 assert_eq!(
336 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
337 "/a (+2)"
338 );
339 }
340
341 #[test]
342 fn age_secs_handles_absent_and_unparseable_and_past() {
343 assert_eq!(age_secs(None), 0);
344 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
345 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
346 }
347
348 #[test]
349 fn reply_payload_unwraps_ok_and_maps_errors() {
350 assert_eq!(
352 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
353 json!({ "a": 1 })
354 );
355 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
357 assert!(err.to_string().contains("boom"), "{err}");
358 let err = reply_payload(DaemonReply {
360 ok: false,
361 payload: Value::Null,
362 error: None,
363 })
364 .unwrap_err();
365 assert!(err.to_string().contains("unknown error"), "{err}");
366 }
367}