1use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Result};
14use chrono::Utc;
15use clap::{Parser, Subcommand};
16use serde_json::Value;
17
18use crate::cli::format::TableOrJson;
19use crate::daemon::client::DaemonClient;
20use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
21use crate::daemon::server;
22
23const SERVICE: &str = "worktrees";
25
26#[derive(Parser)]
29pub struct WorktreesCommand {
30 #[command(subcommand)]
32 pub command: WorktreesSubcommands,
33}
34
35#[derive(Subcommand)]
37pub enum WorktreesSubcommands {
38 List(ListCommand),
40 Tree(TreeCommand),
42}
43
44impl WorktreesCommand {
45 pub async fn execute(self) -> Result<()> {
47 match self.command {
48 WorktreesSubcommands::List(cmd) => cmd.execute().await,
49 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
50 }
51 }
52}
53
54#[derive(Parser)]
56pub struct ListCommand {
57 #[arg(long, value_name = "PATH")]
59 pub socket: Option<PathBuf>,
60 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
62 pub output: TableOrJson,
63 #[arg(long, hide = true)]
65 pub json: bool,
66}
67
68impl ListCommand {
69 pub async fn execute(mut self) -> Result<()> {
71 if self.json {
72 eprintln!("warning: --json is deprecated; use -o/--output json instead");
73 self.output = TableOrJson::Json;
74 }
75 let socket = server::resolve_socket(self.socket)?;
76 let result = call(&socket, "list", Value::Null).await?;
77 match self.output {
78 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
79 TableOrJson::Table => println!("{}", render_windows(&result)),
80 }
81 Ok(())
82 }
83}
84
85#[derive(Parser)]
89pub struct TreeCommand {
90 #[arg(long, value_name = "PATH")]
92 pub socket: Option<PathBuf>,
93 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
95 pub output: TableOrJson,
96}
97
98impl TreeCommand {
99 pub async fn execute(self) -> Result<()> {
101 let socket = server::resolve_socket(self.socket)?;
102 let result = call(&socket, "tree", Value::Null).await?;
103 match self.output {
104 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
105 TableOrJson::Table => println!("{}", render_tree(&result)),
106 }
107 Ok(())
108 }
109}
110
111async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
114 let reply = DaemonClient::new(socket)
115 .request(DaemonEnvelope::service(SERVICE, op, payload))
116 .await?;
117 reply_payload(reply)
118}
119
120fn reply_payload(reply: DaemonReply) -> Result<Value> {
123 if reply.ok {
124 Ok(reply.payload)
125 } else {
126 bail!(
127 "daemon returned an error: {}",
128 reply.error.as_deref().unwrap_or("unknown error")
129 )
130 }
131}
132
133fn render_windows(result: &Value) -> String {
138 let windows = result
139 .get("windows")
140 .and_then(Value::as_array)
141 .map(Vec::as_slice)
142 .unwrap_or_default();
143 if windows.is_empty() {
144 return "No open windows.".to_string();
145 }
146 let mut out = format!(
147 "{:<22} {:<24} {:<9} {:<40} {:>5}",
148 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
149 );
150 for window in windows {
151 let repo = sanitize(repo_name(window));
152 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
153 let sync = sync_summary(window);
154 let folder_disp = folder_summary(window);
155 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
156 out.push_str(&format!(
157 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
158 ));
159 }
160 out
161}
162
163fn render_tree(result: &Value) -> String {
169 let repos = result
170 .get("repos")
171 .and_then(Value::as_array)
172 .map(Vec::as_slice)
173 .unwrap_or_default();
174 if repos.is_empty() {
175 return "No repositories open.".to_string();
176 }
177 let mut out = String::new();
178 for (i, repo) in repos.iter().enumerate() {
179 if i > 0 {
182 out.push_str("\n\n");
183 }
184 out.push_str(&repo_header(repo));
185 for worktree in repo
186 .get("worktrees")
187 .and_then(Value::as_array)
188 .map(Vec::as_slice)
189 .unwrap_or_default()
190 {
191 out.push('\n');
192 out.push_str(&worktree_row(worktree));
193 }
194 }
195 out
196}
197
198fn repo_header(repo: &Value) -> String {
201 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
202 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
203 match github_summary(repo) {
204 Some(github) => format!("{name} ({github}) {root}"),
205 None => format!("{name} {root}"),
206 }
207}
208
209fn github_summary(repo: &Value) -> Option<String> {
212 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
213 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
214 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
215}
216
217fn worktree_row(worktree: &Value) -> String {
221 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
222 '*'
223 } else {
224 ' '
225 };
226 let branch = sanitize(
227 worktree
228 .get("branch")
229 .and_then(Value::as_str)
230 .unwrap_or("-"),
231 );
232 let sync = sync_summary(worktree);
233 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
234 "open"
235 } else {
236 ""
237 };
238 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
239 format!(" {marker} {branch:<24} {sync:<9} {open:<5} {path}")
240}
241
242fn repo_name(window: &Value) -> &str {
246 window
247 .get("main_repo")
248 .and_then(Value::as_str)
249 .or_else(|| window.get("repo").and_then(Value::as_str))
250 .unwrap_or("-")
251}
252
253fn sync_summary(window: &Value) -> String {
257 let ahead = window.get("ahead").and_then(Value::as_u64);
258 let behind = window.get("behind").and_then(Value::as_u64);
259 match (ahead, behind) {
260 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
261 _ => "-".to_string(),
262 }
263}
264
265fn folder_summary(window: &Value) -> String {
268 let folders = window
269 .get("folders")
270 .and_then(Value::as_array)
271 .map(Vec::as_slice)
272 .unwrap_or_default();
273 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
274 let extra = folders.len().saturating_sub(1);
275 if extra > 0 {
276 format!("{first} (+{extra})")
277 } else {
278 first
279 }
280}
281
282fn sanitize(s: &str) -> String {
286 s.chars().filter(|c| !c.is_control()).collect()
287}
288
289fn age_secs(ts: Option<&str>) -> i64 {
291 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
292 .map_or(0, |t| {
293 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
294 })
295}
296
297#[cfg(test)]
298#[allow(clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300 use super::*;
301 use serde_json::json;
302
303 #[derive(Parser)]
305 struct Wrapper {
306 #[command(subcommand)]
307 cmd: WorktreesSubcommands,
308 }
309
310 fn parse(args: &[&str]) -> WorktreesSubcommands {
311 let mut full = vec!["omni-dev"];
312 full.extend_from_slice(args);
313 Wrapper::try_parse_from(full).unwrap().cmd
314 }
315
316 #[test]
317 fn list_parses_flags_and_defaults() {
318 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
320 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
322 assert_eq!(cmd.output, TableOrJson::Table);
323 assert!(!cmd.json);
324 assert!(cmd.socket.is_none());
325
326 let cmd =
327 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
328 assert_eq!(cmd.output, TableOrJson::Json);
329 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
330 }
331
332 #[test]
333 fn list_deprecated_json_flag_still_parses() {
334 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
336 assert!(cmd.json);
337 assert_eq!(cmd.output, TableOrJson::Table);
338 }
339
340 #[test]
341 fn tree_parses_flags_and_defaults() {
342 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
344 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
345 assert_eq!(cmd.output, TableOrJson::Table);
346 assert!(cmd.socket.is_none());
347
348 let cmd =
349 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
350 assert_eq!(cmd.output, TableOrJson::Json);
351 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
352 }
353
354 #[test]
355 fn render_windows_handles_empty_replies() {
356 assert_eq!(
357 render_windows(&json!({ "windows": [] })),
358 "No open windows."
359 );
360 assert_eq!(render_windows(&json!({})), "No open windows.");
361 }
362
363 #[test]
364 fn render_windows_renders_rows() {
365 let result = json!({ "windows": [{
366 "key": "w1",
367 "repo": "omni-dev",
368 "branch": "issue-1011",
369 "ahead": 2,
370 "behind": 1,
371 "folders": ["/home/me/omni-dev", "/home/me/docs"],
372 "last_seen": "2000-01-01T00:00:00Z",
373 }]});
374 let table = render_windows(&result);
375 assert!(table.contains("omni-dev"), "{table}");
376 assert!(table.contains("issue-1011"), "{table}");
378 assert!(table.contains("+2 -1"), "{table}");
379 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
381 assert_eq!(table.lines().count(), 2, "{table}");
383 }
384
385 #[test]
386 fn render_windows_prefers_main_repo_over_companion_repo() {
387 let result = json!({ "windows": [{
391 "key": "w1",
392 "repo": "issue-1250",
393 "main_repo": "omni-dev",
394 "branch": "issue-1250",
395 "folders": ["/home/me/worktrees/issue-1250"],
396 "last_seen": "2000-01-01T00:00:00Z",
397 }]});
398 let table = render_windows(&result);
399 assert!(table.contains("omni-dev"), "{table}");
400 let data_row = table.lines().nth(1).unwrap();
403 assert!(data_row.starts_with("omni-dev"), "{data_row}");
404 }
405
406 #[test]
407 fn repo_name_falls_back_to_companion_repo_then_dash() {
408 assert_eq!(
409 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
410 "omni-dev"
411 );
412 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
413 assert_eq!(repo_name(&json!({})), "-");
414 }
415
416 #[test]
417 fn render_windows_strips_control_bytes() {
418 let result = json!({ "windows": [{
421 "key": "w1",
422 "repo": "evil\x1b[31mrepo",
423 "branch": "br\ranch\x07\u{9b}2J",
424 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
425 "last_seen": "2000-01-01T00:00:00Z",
426 }]});
427 let table = render_windows(&result);
428 assert!(
429 !table.contains(|c: char| c.is_control() && c != '\n'),
430 "{table:?}"
431 );
432 assert!(table.contains("evil[31mrepo"), "{table:?}");
434 assert!(table.contains("branch2J"), "{table:?}");
435 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
436 assert_eq!(table.lines().count(), 2, "{table:?}");
438 }
439
440 #[test]
441 fn sync_summary_formats_or_dashes() {
442 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
443 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
444 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
446 assert_eq!(sync_summary(&json!({})), "-");
447 }
448
449 #[test]
450 fn folder_summary_strips_control_bytes() {
451 assert_eq!(
452 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
453 "/a[2J/b"
454 );
455 }
456
457 #[test]
458 fn folder_summary_counts_extra_folders() {
459 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
460 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
461 assert_eq!(
462 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
463 "/a (+2)"
464 );
465 }
466
467 #[test]
468 fn age_secs_handles_absent_and_unparseable_and_past() {
469 assert_eq!(age_secs(None), 0);
470 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
471 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
472 }
473
474 #[test]
475 fn render_tree_handles_empty_replies() {
476 assert_eq!(
477 render_tree(&json!({ "repos": [] })),
478 "No repositories open."
479 );
480 assert_eq!(render_tree(&json!({})), "No repositories open.");
481 }
482
483 #[test]
484 fn render_tree_groups_repos_and_worktrees() {
485 let result = json!({ "repos": [{
486 "main_repo": "omni-dev",
487 "github": { "owner": "rust-works", "name": "omni-dev" },
488 "root": "/home/me/omni-dev",
489 "worktrees": [
490 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
491 "is_main": true, "open": true, "window_key": "w1" },
492 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
493 "is_main": false, "open": false },
494 ],
495 }]});
496 let out = render_tree(&result);
497 let header = out.lines().next().unwrap();
499 assert!(header.contains("omni-dev"), "{out}");
500 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
501 assert!(header.contains("/home/me/omni-dev"), "{out}");
502 assert!(
504 out.lines()
505 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
506 "{out}"
507 );
508 let linked = out
510 .lines()
511 .find(|l| l.contains("issue-1300"))
512 .unwrap_or_default();
513 assert!(!linked.contains('*'), "{linked}");
514 assert!(!linked.contains("open"), "{linked}");
515 assert!(linked.contains("+1 -3"), "{linked}");
516 assert_eq!(out.lines().count(), 3, "{out}");
518 }
519
520 #[test]
521 fn render_tree_separates_multiple_repos_with_blank_line() {
522 let result = json!({ "repos": [
523 {
524 "main_repo": "alpha",
525 "root": "/r/alpha",
526 "worktrees": [
527 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
528 ],
529 },
530 {
531 "main_repo": "beta",
532 "root": "/r/beta",
533 "worktrees": [
534 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
535 ],
536 },
537 ]});
538 let out = render_tree(&result);
539 assert!(
541 out.contains("\n\nbeta"),
542 "repos not blank-separated: {out:?}"
543 );
544 let alpha = out.find("alpha").unwrap();
545 let beta = out.find("beta").unwrap();
546 assert!(alpha < beta, "repo order not preserved: {out}");
547 assert_eq!(out.lines().count(), 5, "{out:?}");
548 }
549
550 #[test]
551 fn render_tree_omits_github_for_non_github_repo() {
552 let result = json!({ "repos": [{
553 "main_repo": "internal",
554 "root": "/srv/internal",
555 "worktrees": [
556 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
557 ],
558 }]});
559 let out = render_tree(&result);
560 assert!(!out.contains("github:"), "{out}");
561 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
562 }
563
564 #[test]
565 fn render_tree_strips_control_bytes() {
566 let result = json!({ "repos": [{
569 "main_repo": "evil\x1b[31mrepo",
570 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
571 "root": "/tmp/r\x1b]0;x\x07oot",
572 "worktrees": [
573 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
574 ],
575 }]});
576 let out = render_tree(&result);
577 assert!(
578 !out.contains(|c: char| c.is_control() && c != '\n'),
579 "{out:?}"
580 );
581 assert_eq!(out.lines().count(), 2, "{out:?}");
583 }
584
585 #[test]
586 fn github_summary_needs_both_owner_and_name() {
587 assert_eq!(
588 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
589 Some("github: o/n")
590 );
591 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
592 assert_eq!(github_summary(&json!({})), None);
593 }
594
595 #[test]
596 fn reply_payload_unwraps_ok_and_maps_errors() {
597 assert_eq!(
599 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
600 json!({ "a": 1 })
601 );
602 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
604 assert!(err.to_string().contains("boom"), "{err}");
605 let err = reply_payload(DaemonReply {
607 ok: false,
608 payload: Value::Null,
609 error: None,
610 })
611 .unwrap_err();
612 assert!(err.to_string().contains("unknown error"), "{err}");
613 }
614}