1use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Context, Result};
14use chrono::Utc;
15use clap::{Parser, Subcommand};
16use serde_json::{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 Focus(FocusCommand),
44}
45
46impl WorktreesCommand {
47 pub async fn execute(self) -> Result<()> {
49 match self.command {
50 WorktreesSubcommands::List(cmd) => cmd.execute().await,
51 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
52 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
53 }
54 }
55}
56
57#[derive(Parser)]
59pub struct ListCommand {
60 #[arg(long, value_name = "PATH")]
62 pub socket: Option<PathBuf>,
63 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
65 pub output: TableOrJson,
66 #[arg(long, hide = true)]
68 pub json: bool,
69}
70
71impl ListCommand {
72 pub async fn execute(mut self) -> Result<()> {
74 if self.json {
75 eprintln!("warning: --json is deprecated; use -o/--output json instead");
76 self.output = TableOrJson::Json;
77 }
78 let socket = server::resolve_socket(self.socket)?;
79 let result = call(&socket, "list", Value::Null).await?;
80 match self.output {
81 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
82 TableOrJson::Table => println!("{}", render_windows(&result)),
83 }
84 Ok(())
85 }
86}
87
88#[derive(Parser)]
92pub struct TreeCommand {
93 #[arg(long, value_name = "PATH")]
95 pub socket: Option<PathBuf>,
96 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
98 pub output: TableOrJson,
99}
100
101impl TreeCommand {
102 pub async fn execute(self) -> Result<()> {
104 let socket = server::resolve_socket(self.socket)?;
105 let mut result = call(&socket, "tree", Value::Null).await?;
106 enrich_ahead_behind(&socket, &mut result).await;
112 match self.output {
113 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
114 TableOrJson::Table => println!("{}", render_tree(&result)),
115 }
116 Ok(())
117 }
118}
119
120#[derive(Parser)]
127pub struct FocusCommand {
128 #[arg(value_name = "PATH")]
130 pub path: PathBuf,
131 #[arg(long, value_name = "PATH")]
133 pub socket: Option<PathBuf>,
134}
135
136impl FocusCommand {
137 pub async fn execute(self) -> Result<()> {
139 let path = std::fs::canonicalize(&self.path)
143 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
144 let socket = server::resolve_socket(self.socket)?;
145 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
146 println!("Focused {}", path.display());
147 Ok(())
148 }
149}
150
151async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
158 let paths = worktree_paths(result);
159 if paths.is_empty() {
160 return;
161 }
162 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
163 return;
164 };
165 if let Some(results) = reply.get("results").and_then(Value::as_object) {
166 merge_ahead_behind(result, results);
167 }
168}
169
170fn worktree_paths(result: &Value) -> Vec<String> {
173 let mut paths = Vec::new();
174 for repo in result
175 .get("repos")
176 .and_then(Value::as_array)
177 .map(Vec::as_slice)
178 .unwrap_or_default()
179 {
180 for worktree in repo
181 .get("worktrees")
182 .and_then(Value::as_array)
183 .map(Vec::as_slice)
184 .unwrap_or_default()
185 {
186 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
187 paths.push(path.to_string());
188 }
189 }
190 }
191 paths
192}
193
194fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
199 for repo in result
200 .get_mut("repos")
201 .and_then(Value::as_array_mut)
202 .into_iter()
203 .flatten()
204 {
205 for worktree in repo
206 .get_mut("worktrees")
207 .and_then(Value::as_array_mut)
208 .into_iter()
209 .flatten()
210 {
211 let Some(obj) = worktree.as_object_mut() else {
215 continue;
216 };
217 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
218 continue;
219 };
220 let Some(counts) = results.get(&path) else {
221 continue;
222 };
223 if let (Some(ahead), Some(behind)) =
226 (counts.get("ahead").cloned(), counts.get("behind").cloned())
227 {
228 obj.insert("ahead".to_string(), ahead);
229 obj.insert("behind".to_string(), behind);
230 }
231 }
232 }
233}
234
235async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
238 let reply = DaemonClient::new(socket)
239 .request(DaemonEnvelope::service(SERVICE, op, payload))
240 .await?;
241 reply_payload(reply)
242}
243
244fn reply_payload(reply: DaemonReply) -> Result<Value> {
247 if reply.ok {
248 Ok(reply.payload)
249 } else {
250 bail!(
251 "daemon returned an error: {}",
252 reply.error.as_deref().unwrap_or("unknown error")
253 )
254 }
255}
256
257fn render_windows(result: &Value) -> String {
262 let windows = result
263 .get("windows")
264 .and_then(Value::as_array)
265 .map(Vec::as_slice)
266 .unwrap_or_default();
267 if windows.is_empty() {
268 return "No open windows.".to_string();
269 }
270 let mut out = format!(
271 "{:<22} {:<24} {:<9} {:<40} {:>5}",
272 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
273 );
274 for window in windows {
275 let repo = sanitize(repo_name(window));
276 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
277 let sync = sync_summary(window);
278 let folder_disp = folder_summary(window);
279 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
280 out.push_str(&format!(
281 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
282 ));
283 }
284 out
285}
286
287fn render_tree(result: &Value) -> String {
293 let repos = result
294 .get("repos")
295 .and_then(Value::as_array)
296 .map(Vec::as_slice)
297 .unwrap_or_default();
298 if repos.is_empty() {
299 return "No repositories open.".to_string();
300 }
301 let mut out = String::new();
302 for (i, repo) in repos.iter().enumerate() {
303 if i > 0 {
306 out.push_str("\n\n");
307 }
308 out.push_str(&repo_header(repo));
309 for worktree in repo
310 .get("worktrees")
311 .and_then(Value::as_array)
312 .map(Vec::as_slice)
313 .unwrap_or_default()
314 {
315 out.push('\n');
316 out.push_str(&worktree_row(worktree));
317 }
318 }
319 out
320}
321
322fn repo_header(repo: &Value) -> String {
325 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
326 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
327 match github_summary(repo) {
328 Some(github) => format!("{name} ({github}) {root}"),
329 None => format!("{name} {root}"),
330 }
331}
332
333fn github_summary(repo: &Value) -> Option<String> {
336 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
337 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
338 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
339}
340
341fn worktree_row(worktree: &Value) -> String {
345 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
346 '*'
347 } else {
348 ' '
349 };
350 let branch = sanitize(
351 worktree
352 .get("branch")
353 .and_then(Value::as_str)
354 .unwrap_or("-"),
355 );
356 let sync = sync_summary(worktree);
357 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
358 "open"
359 } else {
360 ""
361 };
362 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
363 format!(" {marker} {branch:<24} {sync:<9} {open:<5} {path}")
364}
365
366fn repo_name(window: &Value) -> &str {
370 window
371 .get("main_repo")
372 .and_then(Value::as_str)
373 .or_else(|| window.get("repo").and_then(Value::as_str))
374 .unwrap_or("-")
375}
376
377fn sync_summary(window: &Value) -> String {
381 let ahead = window.get("ahead").and_then(Value::as_u64);
382 let behind = window.get("behind").and_then(Value::as_u64);
383 match (ahead, behind) {
384 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
385 _ => "-".to_string(),
386 }
387}
388
389fn folder_summary(window: &Value) -> String {
392 let folders = window
393 .get("folders")
394 .and_then(Value::as_array)
395 .map(Vec::as_slice)
396 .unwrap_or_default();
397 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
398 let extra = folders.len().saturating_sub(1);
399 if extra > 0 {
400 format!("{first} (+{extra})")
401 } else {
402 first
403 }
404}
405
406fn sanitize(s: &str) -> String {
410 s.chars().filter(|c| !c.is_control()).collect()
411}
412
413fn age_secs(ts: Option<&str>) -> i64 {
415 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
416 .map_or(0, |t| {
417 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
418 })
419}
420
421#[cfg(test)]
422#[allow(clippy::unwrap_used, clippy::expect_used)]
423mod tests {
424 use super::*;
425 use serde_json::json;
426
427 #[derive(Parser)]
429 struct Wrapper {
430 #[command(subcommand)]
431 cmd: WorktreesSubcommands,
432 }
433
434 fn parse(args: &[&str]) -> WorktreesSubcommands {
435 let mut full = vec!["omni-dev"];
436 full.extend_from_slice(args);
437 Wrapper::try_parse_from(full).unwrap().cmd
438 }
439
440 #[test]
441 fn list_parses_flags_and_defaults() {
442 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
444 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
446 assert_eq!(cmd.output, TableOrJson::Table);
447 assert!(!cmd.json);
448 assert!(cmd.socket.is_none());
449
450 let cmd =
451 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
452 assert_eq!(cmd.output, TableOrJson::Json);
453 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
454 }
455
456 #[test]
457 fn list_deprecated_json_flag_still_parses() {
458 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
460 assert!(cmd.json);
461 assert_eq!(cmd.output, TableOrJson::Table);
462 }
463
464 #[test]
465 fn tree_parses_flags_and_defaults() {
466 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
468 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
469 assert_eq!(cmd.output, TableOrJson::Table);
470 assert!(cmd.socket.is_none());
471
472 let cmd =
473 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
474 assert_eq!(cmd.output, TableOrJson::Json);
475 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
476 }
477
478 #[test]
479 fn focus_parses_path_and_socket() {
480 assert!(matches!(
482 parse(&["focus", "/home/me/wt"]),
483 WorktreesSubcommands::Focus(_)
484 ));
485 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
487 assert_eq!(cmd.path, Path::new("/home/me/wt"));
488 assert!(cmd.socket.is_none());
489
490 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
491 .unwrap();
492 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
493
494 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
496 }
497
498 #[tokio::test]
499 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
500 let cmd = FocusCommand {
503 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
504 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
505 };
506 let err = cmd.execute().await.unwrap_err();
507 assert!(
508 err.to_string().contains("cannot resolve worktree path"),
509 "{err}"
510 );
511 }
512
513 #[tokio::test]
514 async fn focus_sends_the_open_op_for_an_existing_folder() {
515 let (_dir, sock, server) =
519 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
520 let target = tempfile::tempdir().unwrap();
521 let cmd = WorktreesCommand {
522 command: WorktreesSubcommands::Focus(FocusCommand {
523 path: target.path().to_path_buf(),
524 socket: Some(sock),
525 }),
526 };
527 cmd.execute().await.unwrap();
528 server.await.unwrap();
529 }
530
531 #[test]
532 fn render_windows_handles_empty_replies() {
533 assert_eq!(
534 render_windows(&json!({ "windows": [] })),
535 "No open windows."
536 );
537 assert_eq!(render_windows(&json!({})), "No open windows.");
538 }
539
540 #[test]
541 fn render_windows_renders_rows() {
542 let result = json!({ "windows": [{
543 "key": "w1",
544 "repo": "omni-dev",
545 "branch": "issue-1011",
546 "ahead": 2,
547 "behind": 1,
548 "folders": ["/home/me/omni-dev", "/home/me/docs"],
549 "last_seen": "2000-01-01T00:00:00Z",
550 }]});
551 let table = render_windows(&result);
552 assert!(table.contains("omni-dev"), "{table}");
553 assert!(table.contains("issue-1011"), "{table}");
555 assert!(table.contains("+2 -1"), "{table}");
556 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
558 assert_eq!(table.lines().count(), 2, "{table}");
560 }
561
562 #[test]
563 fn render_windows_prefers_main_repo_over_companion_repo() {
564 let result = json!({ "windows": [{
568 "key": "w1",
569 "repo": "issue-1250",
570 "main_repo": "omni-dev",
571 "branch": "issue-1250",
572 "folders": ["/home/me/worktrees/issue-1250"],
573 "last_seen": "2000-01-01T00:00:00Z",
574 }]});
575 let table = render_windows(&result);
576 assert!(table.contains("omni-dev"), "{table}");
577 let data_row = table.lines().nth(1).unwrap();
580 assert!(data_row.starts_with("omni-dev"), "{data_row}");
581 }
582
583 #[test]
584 fn repo_name_falls_back_to_companion_repo_then_dash() {
585 assert_eq!(
586 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
587 "omni-dev"
588 );
589 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
590 assert_eq!(repo_name(&json!({})), "-");
591 }
592
593 #[test]
594 fn render_windows_strips_control_bytes() {
595 let result = json!({ "windows": [{
598 "key": "w1",
599 "repo": "evil\x1b[31mrepo",
600 "branch": "br\ranch\x07\u{9b}2J",
601 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
602 "last_seen": "2000-01-01T00:00:00Z",
603 }]});
604 let table = render_windows(&result);
605 assert!(
606 !table.contains(|c: char| c.is_control() && c != '\n'),
607 "{table:?}"
608 );
609 assert!(table.contains("evil[31mrepo"), "{table:?}");
611 assert!(table.contains("branch2J"), "{table:?}");
612 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
613 assert_eq!(table.lines().count(), 2, "{table:?}");
615 }
616
617 #[test]
618 fn sync_summary_formats_or_dashes() {
619 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
620 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
621 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
623 assert_eq!(sync_summary(&json!({})), "-");
624 }
625
626 #[test]
627 fn folder_summary_strips_control_bytes() {
628 assert_eq!(
629 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
630 "/a[2J/b"
631 );
632 }
633
634 #[test]
635 fn folder_summary_counts_extra_folders() {
636 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
637 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
638 assert_eq!(
639 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
640 "/a (+2)"
641 );
642 }
643
644 #[test]
645 fn age_secs_handles_absent_and_unparseable_and_past() {
646 assert_eq!(age_secs(None), 0);
647 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
648 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
649 }
650
651 #[test]
652 fn render_tree_handles_empty_replies() {
653 assert_eq!(
654 render_tree(&json!({ "repos": [] })),
655 "No repositories open."
656 );
657 assert_eq!(render_tree(&json!({})), "No repositories open.");
658 }
659
660 #[test]
661 fn worktree_paths_collects_every_worktree_in_render_order() {
662 let result = json!({ "repos": [
663 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
665 { "worktrees": [ { "path": "/c" } ] },
666 ]});
667 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
668 assert!(worktree_paths(&json!({})).is_empty());
670 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
671 }
672
673 #[test]
674 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
675 let mut result = json!({ "repos": [{ "worktrees": [
679 { "path": "/a", "branch": "main" },
680 { "path": "/b", "branch": "feature" },
681 ]}]});
682 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
683 merge_ahead_behind(&mut result, results.as_object().unwrap());
684
685 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
686 let a = &worktrees[0];
687 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
688 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
689 assert_eq!(sync_summary(a), "+2 -1");
691 let b = &worktrees[1];
692 assert!(b.get("ahead").is_none(), "{b:?}");
693 assert!(b.get("behind").is_none(), "{b:?}");
694 assert_eq!(sync_summary(b), "-");
695 }
696
697 #[test]
698 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
699 let mut result = json!({ "repos": [{ "worktrees": [
703 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
707 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
709
710 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
711 assert_eq!(worktrees[0], json!("not-an-object"));
713 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
715 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
717 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
718 }
719
720 #[tokio::test]
721 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
722 let mut result = json!({ "repos": [] });
725 let before = result.clone();
726 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
727 assert_eq!(result, before);
728 }
729
730 #[tokio::test]
731 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
732 let mut result =
735 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
736 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
737 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
738 assert!(wt.get("ahead").is_none(), "{wt:?}");
739 assert!(wt.get("behind").is_none(), "{wt:?}");
740 }
741
742 fn fake_daemon_reply(
747 reply: Value,
748 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
749 use futures::{SinkExt, StreamExt};
750 use tokio::net::UnixListener;
751 use tokio_util::codec::{Framed, LinesCodec};
752
753 let dir = tempfile::tempdir_in("/tmp").unwrap();
755 let sock = dir.path().join("d.sock");
756 let listener = UnixListener::bind(&sock).unwrap();
757 let server = tokio::spawn(async move {
758 let (stream, _) = listener.accept().await.unwrap();
759 let mut framed = Framed::new(stream, LinesCodec::new());
760 let _req = framed.next().await.unwrap().unwrap();
761 framed
762 .send(serde_json::to_string(&reply).unwrap())
763 .await
764 .unwrap();
765 });
766 (dir, sock, server)
767 }
768
769 #[tokio::test]
770 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
771 let (_dir, sock, server) = fake_daemon_reply(
772 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
773 );
774 let mut result =
775 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
776 enrich_ahead_behind(&sock, &mut result).await;
777 server.await.unwrap();
778
779 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
780 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
781 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
782 }
783
784 #[tokio::test]
785 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
786 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
789 let mut result =
790 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
791 enrich_ahead_behind(&sock, &mut result).await;
792 server.await.unwrap();
793
794 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
795 assert!(wt.get("ahead").is_none(), "{wt:?}");
796 assert!(wt.get("behind").is_none(), "{wt:?}");
797 }
798
799 #[test]
800 fn render_tree_groups_repos_and_worktrees() {
801 let result = json!({ "repos": [{
802 "main_repo": "omni-dev",
803 "github": { "owner": "rust-works", "name": "omni-dev" },
804 "root": "/home/me/omni-dev",
805 "worktrees": [
806 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
807 "is_main": true, "open": true, "window_key": "w1" },
808 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
809 "is_main": false, "open": false },
810 ],
811 }]});
812 let out = render_tree(&result);
813 let header = out.lines().next().unwrap();
815 assert!(header.contains("omni-dev"), "{out}");
816 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
817 assert!(header.contains("/home/me/omni-dev"), "{out}");
818 assert!(
820 out.lines()
821 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
822 "{out}"
823 );
824 let linked = out
826 .lines()
827 .find(|l| l.contains("issue-1300"))
828 .unwrap_or_default();
829 assert!(!linked.contains('*'), "{linked}");
830 assert!(!linked.contains("open"), "{linked}");
831 assert!(linked.contains("+1 -3"), "{linked}");
832 assert_eq!(out.lines().count(), 3, "{out}");
834 }
835
836 #[test]
837 fn render_tree_separates_multiple_repos_with_blank_line() {
838 let result = json!({ "repos": [
839 {
840 "main_repo": "alpha",
841 "root": "/r/alpha",
842 "worktrees": [
843 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
844 ],
845 },
846 {
847 "main_repo": "beta",
848 "root": "/r/beta",
849 "worktrees": [
850 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
851 ],
852 },
853 ]});
854 let out = render_tree(&result);
855 assert!(
857 out.contains("\n\nbeta"),
858 "repos not blank-separated: {out:?}"
859 );
860 let alpha = out.find("alpha").unwrap();
861 let beta = out.find("beta").unwrap();
862 assert!(alpha < beta, "repo order not preserved: {out}");
863 assert_eq!(out.lines().count(), 5, "{out:?}");
864 }
865
866 #[test]
867 fn render_tree_omits_github_for_non_github_repo() {
868 let result = json!({ "repos": [{
869 "main_repo": "internal",
870 "root": "/srv/internal",
871 "worktrees": [
872 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
873 ],
874 }]});
875 let out = render_tree(&result);
876 assert!(!out.contains("github:"), "{out}");
877 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
878 }
879
880 #[test]
881 fn render_tree_strips_control_bytes() {
882 let result = json!({ "repos": [{
885 "main_repo": "evil\x1b[31mrepo",
886 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
887 "root": "/tmp/r\x1b]0;x\x07oot",
888 "worktrees": [
889 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
890 ],
891 }]});
892 let out = render_tree(&result);
893 assert!(
894 !out.contains(|c: char| c.is_control() && c != '\n'),
895 "{out:?}"
896 );
897 assert_eq!(out.lines().count(), 2, "{out:?}");
899 }
900
901 #[test]
902 fn github_summary_needs_both_owner_and_name() {
903 assert_eq!(
904 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
905 Some("github: o/n")
906 );
907 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
908 assert_eq!(github_summary(&json!({})), None);
909 }
910
911 #[test]
912 fn reply_payload_unwraps_ok_and_maps_errors() {
913 assert_eq!(
915 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
916 json!({ "a": 1 })
917 );
918 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
920 assert!(err.to_string().contains("boom"), "{err}");
921 let err = reply_payload(DaemonReply {
923 ok: false,
924 payload: Value::Null,
925 error: None,
926 })
927 .unwrap_err();
928 assert!(err.to_string().contains("unknown error"), "{err}");
929 }
930}