1use std::path::{Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15use chrono::Utc;
16use clap::{Parser, Subcommand};
17use serde_json::{json, Value};
18
19use crate::cli::format::TableOrJson;
20use crate::daemon::client::DaemonClient;
21use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
22use crate::daemon::server;
23
24const SERVICE: &str = "worktrees";
26
27#[derive(Parser)]
30pub struct WorktreesCommand {
31 #[command(subcommand)]
33 pub command: WorktreesSubcommands,
34}
35
36#[derive(Subcommand)]
38pub enum WorktreesSubcommands {
39 List(ListCommand),
41 Tree(TreeCommand),
43 Focus(FocusCommand),
45 Close(CloseCommand),
47 ShowClosed(ShowClosedCommand),
49 Register(RegisterCommand),
51 Heartbeat(HeartbeatCommand),
53 Unregister(UnregisterCommand),
55}
56
57impl WorktreesCommand {
58 pub async fn execute(self) -> Result<()> {
60 match self.command {
61 WorktreesSubcommands::List(cmd) => cmd.execute().await,
62 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
63 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
64 WorktreesSubcommands::Close(cmd) => cmd.execute().await,
65 WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
66 WorktreesSubcommands::Register(cmd) => cmd.execute().await,
67 WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
68 WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
69 }
70 }
71}
72
73#[derive(Parser)]
75pub struct ListCommand {
76 #[arg(long, value_name = "PATH")]
78 pub socket: Option<PathBuf>,
79 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
81 pub output: TableOrJson,
82 #[arg(long, hide = true)]
84 pub json: bool,
85}
86
87impl ListCommand {
88 pub async fn execute(mut self) -> Result<()> {
90 if self.json {
91 eprintln!("warning: --json is deprecated; use -o/--output json instead");
92 self.output = TableOrJson::Json;
93 }
94 let socket = server::resolve_socket(self.socket)?;
95 let result = call(&socket, "list", Value::Null).await?;
96 match self.output {
97 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
98 TableOrJson::Table => println!("{}", render_windows(&result)),
99 }
100 Ok(())
101 }
102}
103
104#[derive(Parser)]
108pub struct TreeCommand {
109 #[arg(long, value_name = "PATH")]
111 pub socket: Option<PathBuf>,
112 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
114 pub output: TableOrJson,
115 #[arg(short = 'f', long)]
118 pub follow: bool,
119}
120
121impl TreeCommand {
122 pub async fn execute(self) -> Result<()> {
124 let socket = server::resolve_socket(self.socket)?;
125 if self.follow {
126 return follow_tree_stream(&socket, self.output).await;
127 }
128 let mut result = call(&socket, "tree", Value::Null).await?;
129 enrich_ahead_behind(&socket, &mut result).await;
135 match self.output {
136 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
137 TableOrJson::Table => println!("{}", render_tree(&result)),
138 }
139 Ok(())
140 }
141}
142
143async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
150 let mut sub = DaemonClient::new(socket)
151 .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
152 .await?;
153 loop {
154 tokio::select! {
155 frame = sub.next() => {
156 let Some(frame) = frame else { break };
158 let mut payload = reply_payload(frame?)?;
159 enrich_ahead_behind(socket, &mut payload).await;
163 match output {
164 TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
166 TableOrJson::Table => println!("{}", render_tree(&payload)),
167 }
168 }
169 _ = tokio::signal::ctrl_c() => break,
172 }
173 }
174 Ok(())
175}
176
177#[derive(Parser)]
184pub struct FocusCommand {
185 #[arg(value_name = "PATH")]
187 pub path: PathBuf,
188 #[arg(long, value_name = "PATH")]
190 pub socket: Option<PathBuf>,
191}
192
193impl FocusCommand {
194 pub async fn execute(self) -> Result<()> {
196 let path = std::fs::canonicalize(&self.path)
200 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
201 let socket = server::resolve_socket(self.socket)?;
202 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
203 println!("Focused {}", path.display());
204 Ok(())
205 }
206}
207
208#[derive(Parser)]
217pub struct CloseCommand {
218 #[arg(value_name = "PATH")]
221 pub path: PathBuf,
222 #[arg(long)]
224 pub window_only: bool,
225 #[arg(long)]
227 pub dry_run: bool,
228 #[arg(short = 'y', long)]
230 pub yes: bool,
231 #[arg(long, value_name = "PATH")]
233 pub socket: Option<PathBuf>,
234}
235
236impl CloseCommand {
237 pub async fn execute(self) -> Result<()> {
239 self.execute_with(confirm_removal).await
240 }
241
242 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
247 where
248 F: FnOnce(bool) -> Fut,
249 Fut: std::future::Future<Output = bool>,
250 {
251 let path = std::fs::canonicalize(&self.path)
254 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
255 let path_str = path.to_string_lossy().to_string();
256 let socket = server::resolve_socket(self.socket)?;
257
258 if self.window_only {
262 if self.dry_run {
263 println!(
264 "Would close the window for {} (dry run; nothing closed)",
265 path.display()
266 );
267 return Ok(());
268 }
269 call(
270 &socket,
271 "close",
272 json!({ "path": path_str, "remove": false }),
273 )
274 .await?;
275 println!("Closed the window for {}", path.display());
276 return Ok(());
277 }
278
279 let report = call(
281 &socket,
282 "close",
283 json!({ "path": path_str, "remove": true }),
284 )
285 .await?;
286 println!("{}", render_safety_report(&path, &report));
287
288 if self.dry_run {
289 return Ok(());
290 }
291 if report.get("removable").and_then(Value::as_bool) != Some(true) {
294 bail!(
295 "{} is not a removable worktree (nothing deleted); \
296 use --window-only to just close its window",
297 path.display()
298 );
299 }
300 let has_risks = report
301 .get("risks")
302 .and_then(Value::as_array)
303 .is_some_and(|r| !r.is_empty());
304 if !self.yes && !confirm(has_risks).await {
305 println!("Aborted; nothing was deleted.");
306 return Ok(());
307 }
308
309 call(
311 &socket,
312 "close",
313 json!({ "path": path_str, "remove": true, "confirmed": true }),
314 )
315 .await?;
316 println!("Deleted worktree {}", path.display());
317 Ok(())
318 }
319}
320
321#[derive(Parser)]
327pub struct ShowClosedCommand {
328 #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
330 pub value: Option<bool>,
331 #[arg(long, value_name = "PATH")]
333 pub socket: Option<PathBuf>,
334}
335
336impl ShowClosedCommand {
337 pub async fn execute(self) -> Result<()> {
339 let socket = server::resolve_socket(self.socket)?;
340 if let Some(show_closed) = self.value {
341 call(
342 &socket,
343 "set-show-closed",
344 json!({ "show_closed": show_closed }),
345 )
346 .await?;
347 println!("show-closed: {show_closed}");
348 } else {
349 let tree = call(&socket, "tree", Value::Null).await?;
351 let current = tree
352 .get("show_closed")
353 .and_then(Value::as_bool)
354 .unwrap_or(true);
355 println!("show-closed: {current}");
356 }
357 Ok(())
358 }
359}
360
361#[derive(Parser)]
367pub struct RegisterCommand {
368 #[arg(long, value_name = "KEY")]
370 pub key: String,
371 #[arg(long = "folder", value_name = "PATH")]
373 pub folders: Vec<PathBuf>,
374 #[arg(long, value_name = "REPO")]
376 pub repo: Option<String>,
377 #[arg(long, value_name = "TITLE")]
379 pub title: Option<String>,
380 #[arg(long, value_name = "PID")]
382 pub pid: Option<u32>,
383 #[arg(long, value_name = "PATH")]
385 pub socket: Option<PathBuf>,
386}
387
388impl RegisterCommand {
389 pub async fn execute(self) -> Result<()> {
391 let socket = server::resolve_socket(self.socket)?;
392 let payload = json!({
393 "key": self.key,
394 "folders": self.folders,
395 "repo": self.repo,
396 "title": self.title,
397 "pid": self.pid,
398 });
399 call(&socket, "register", payload).await?;
400 println!("Registered {}", self.key);
401 Ok(())
402 }
403}
404
405#[derive(Parser)]
411pub struct HeartbeatCommand {
412 #[arg(long, value_name = "KEY")]
414 pub key: String,
415 #[arg(long, value_name = "PATH")]
417 pub socket: Option<PathBuf>,
418}
419
420impl HeartbeatCommand {
421 pub async fn execute(self) -> Result<()> {
423 let socket = server::resolve_socket(self.socket)?;
424 let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
425 let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
426 let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
428 println!("known: {known}");
429 println!("close: {close}");
430 Ok(())
431 }
432}
433
434#[derive(Parser)]
437pub struct UnregisterCommand {
438 #[arg(long, value_name = "KEY")]
440 pub key: String,
441 #[arg(long, value_name = "PATH")]
443 pub socket: Option<PathBuf>,
444}
445
446impl UnregisterCommand {
447 pub async fn execute(self) -> Result<()> {
449 let socket = server::resolve_socket(self.socket)?;
450 let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
451 let removed = reply
452 .get("removed")
453 .and_then(Value::as_bool)
454 .unwrap_or(false);
455 println!("removed: {removed}");
456 Ok(())
457 }
458}
459
460fn render_safety_report(path: &Path, report: &Value) -> String {
465 let removable = report
466 .get("removable")
467 .and_then(Value::as_bool)
468 .unwrap_or(false);
469 let is_main = report
470 .get("is_main")
471 .and_then(Value::as_bool)
472 .unwrap_or(false);
473 let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
474 let mut out = format!("Worktree: {}", path.display());
475 out.push_str(&format!("\n removable: {removable}"));
476 out.push_str(&format!("\n main working tree: {is_main}"));
477 if open {
478 let key = sanitize(
479 report
480 .get("window_key")
481 .and_then(Value::as_str)
482 .unwrap_or("-"),
483 );
484 let count = report
485 .get("window_folder_count")
486 .and_then(Value::as_u64)
487 .unwrap_or(0);
488 out.push_str(&format!(
489 "\n open in a window: yes (key {key}, {count} folder(s))"
490 ));
491 } else {
492 out.push_str("\n open in a window: no");
493 }
494 out.push_str(&render_notes("risks", report.get("risks")));
495 out.push_str(&render_notes("info", report.get("info")));
496 out
497}
498
499fn render_notes(label: &str, notes: Option<&Value>) -> String {
502 let notes = notes
503 .and_then(Value::as_array)
504 .map(Vec::as_slice)
505 .unwrap_or_default();
506 if notes.is_empty() {
507 return String::new();
508 }
509 let mut out = format!("\n {label}:");
510 for note in notes {
511 let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
512 let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
513 out.push_str(&format!("\n - [{kind}] {detail}"));
514 }
515 out
516}
517
518async fn confirm_removal(has_risks: bool) -> bool {
525 confirm_removal_with(has_risks, read_stdin_line()).await
526}
527
528async fn confirm_removal_with(
533 has_risks: bool,
534 read: impl std::future::Future<Output = Option<String>>,
535) -> bool {
536 use std::io::Write;
537 eprint!("{}", confirm_prompt(has_risks));
538 let _ = std::io::stderr().flush();
539 read.await.as_deref().is_some_and(answer_is_yes)
540}
541
542async fn read_stdin_line() -> Option<String> {
546 tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
547 .await
548 .ok()
549 .flatten()
550}
551
552fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
557 let mut answer = String::new();
558 reader.read_line(&mut answer).ok().map(|_| answer)
559}
560
561fn confirm_prompt(has_risks: bool) -> &'static str {
564 if has_risks {
565 "Delete this worktree despite the risks above? [y/N] "
566 } else {
567 "Delete this worktree? [y/N] "
568 }
569}
570
571fn answer_is_yes(answer: &str) -> bool {
574 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
575}
576
577async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
584 let paths = worktree_paths(result);
585 if paths.is_empty() {
586 return;
587 }
588 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
589 return;
590 };
591 if let Some(results) = reply.get("results").and_then(Value::as_object) {
592 merge_ahead_behind(result, results);
593 }
594}
595
596fn worktree_paths(result: &Value) -> Vec<String> {
599 let mut paths = Vec::new();
600 for repo in result
601 .get("repos")
602 .and_then(Value::as_array)
603 .map(Vec::as_slice)
604 .unwrap_or_default()
605 {
606 for worktree in repo
607 .get("worktrees")
608 .and_then(Value::as_array)
609 .map(Vec::as_slice)
610 .unwrap_or_default()
611 {
612 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
613 paths.push(path.to_string());
614 }
615 }
616 }
617 paths
618}
619
620fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
625 for repo in result
626 .get_mut("repos")
627 .and_then(Value::as_array_mut)
628 .into_iter()
629 .flatten()
630 {
631 for worktree in repo
632 .get_mut("worktrees")
633 .and_then(Value::as_array_mut)
634 .into_iter()
635 .flatten()
636 {
637 let Some(obj) = worktree.as_object_mut() else {
641 continue;
642 };
643 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
644 continue;
645 };
646 let Some(counts) = results.get(&path) else {
647 continue;
648 };
649 if let (Some(ahead), Some(behind)) =
652 (counts.get("ahead").cloned(), counts.get("behind").cloned())
653 {
654 obj.insert("ahead".to_string(), ahead);
655 obj.insert("behind".to_string(), behind);
656 }
657 }
658 }
659}
660
661async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
664 let reply = DaemonClient::new(socket)
665 .request(DaemonEnvelope::service(SERVICE, op, payload))
666 .await?;
667 reply_payload(reply)
668}
669
670fn reply_payload(reply: DaemonReply) -> Result<Value> {
673 if reply.ok {
674 Ok(reply.payload)
675 } else {
676 bail!(
677 "daemon returned an error: {}",
678 reply.error.as_deref().unwrap_or("unknown error")
679 )
680 }
681}
682
683fn render_windows(result: &Value) -> String {
688 let windows = result
689 .get("windows")
690 .and_then(Value::as_array)
691 .map(Vec::as_slice)
692 .unwrap_or_default();
693 if windows.is_empty() {
694 return "No open windows.".to_string();
695 }
696 let mut out = format!(
697 "{:<22} {:<24} {:<9} {:<40} {:>5}",
698 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
699 );
700 for window in windows {
701 let repo = sanitize(repo_name(window));
702 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
703 let sync = sync_summary(window);
704 let folder_disp = folder_summary(window);
705 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
706 out.push_str(&format!(
707 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
708 ));
709 }
710 out
711}
712
713fn render_tree(result: &Value) -> String {
719 let repos = result
720 .get("repos")
721 .and_then(Value::as_array)
722 .map(Vec::as_slice)
723 .unwrap_or_default();
724 if repos.is_empty() {
725 return "No repositories open.".to_string();
726 }
727 let mut out = String::new();
728 for (i, repo) in repos.iter().enumerate() {
729 if i > 0 {
732 out.push_str("\n\n");
733 }
734 out.push_str(&repo_header(repo));
735 for worktree in repo
736 .get("worktrees")
737 .and_then(Value::as_array)
738 .map(Vec::as_slice)
739 .unwrap_or_default()
740 {
741 out.push('\n');
742 out.push_str(&worktree_row(worktree));
743 }
744 }
745 out
746}
747
748fn repo_header(repo: &Value) -> String {
751 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
752 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
753 match github_summary(repo) {
754 Some(github) => format!("{name} ({github}) {root}"),
755 None => format!("{name} {root}"),
756 }
757}
758
759fn github_summary(repo: &Value) -> Option<String> {
762 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
763 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
764 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
765}
766
767fn worktree_row(worktree: &Value) -> String {
771 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
772 '*'
773 } else {
774 ' '
775 };
776 let branch = sanitize(
777 worktree
778 .get("branch")
779 .and_then(Value::as_str)
780 .unwrap_or("-"),
781 );
782 let sync = sync_summary(worktree);
783 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
784 "open"
785 } else {
786 ""
787 };
788 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
789 format!(" {marker} {branch:<24} {sync:<9} {open:<5} {path}")
790}
791
792fn repo_name(window: &Value) -> &str {
796 window
797 .get("main_repo")
798 .and_then(Value::as_str)
799 .or_else(|| window.get("repo").and_then(Value::as_str))
800 .unwrap_or("-")
801}
802
803fn sync_summary(window: &Value) -> String {
807 let ahead = window.get("ahead").and_then(Value::as_u64);
808 let behind = window.get("behind").and_then(Value::as_u64);
809 match (ahead, behind) {
810 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
811 _ => "-".to_string(),
812 }
813}
814
815fn folder_summary(window: &Value) -> String {
818 let folders = window
819 .get("folders")
820 .and_then(Value::as_array)
821 .map(Vec::as_slice)
822 .unwrap_or_default();
823 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
824 let extra = folders.len().saturating_sub(1);
825 if extra > 0 {
826 format!("{first} (+{extra})")
827 } else {
828 first
829 }
830}
831
832fn sanitize(s: &str) -> String {
836 s.chars().filter(|c| !c.is_control()).collect()
837}
838
839fn age_secs(ts: Option<&str>) -> i64 {
841 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
842 .map_or(0, |t| {
843 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
844 })
845}
846
847#[cfg(test)]
848#[allow(clippy::unwrap_used, clippy::expect_used)]
849mod tests {
850 use super::*;
851 use serde_json::json;
852
853 #[derive(Parser)]
855 struct Wrapper {
856 #[command(subcommand)]
857 cmd: WorktreesSubcommands,
858 }
859
860 fn parse(args: &[&str]) -> WorktreesSubcommands {
861 let mut full = vec!["omni-dev"];
862 full.extend_from_slice(args);
863 Wrapper::try_parse_from(full).unwrap().cmd
864 }
865
866 #[test]
867 fn list_parses_flags_and_defaults() {
868 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
870 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
872 assert_eq!(cmd.output, TableOrJson::Table);
873 assert!(!cmd.json);
874 assert!(cmd.socket.is_none());
875
876 let cmd =
877 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
878 assert_eq!(cmd.output, TableOrJson::Json);
879 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
880 }
881
882 #[test]
883 fn list_deprecated_json_flag_still_parses() {
884 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
886 assert!(cmd.json);
887 assert_eq!(cmd.output, TableOrJson::Table);
888 }
889
890 #[test]
891 fn tree_parses_flags_and_defaults() {
892 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
894 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
895 assert_eq!(cmd.output, TableOrJson::Table);
896 assert!(cmd.socket.is_none());
897
898 let cmd =
899 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
900 assert_eq!(cmd.output, TableOrJson::Json);
901 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
902 }
903
904 #[test]
905 fn focus_parses_path_and_socket() {
906 assert!(matches!(
908 parse(&["focus", "/home/me/wt"]),
909 WorktreesSubcommands::Focus(_)
910 ));
911 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
913 assert_eq!(cmd.path, Path::new("/home/me/wt"));
914 assert!(cmd.socket.is_none());
915
916 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
917 .unwrap();
918 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
919
920 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
922 }
923
924 #[tokio::test]
925 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
926 let cmd = FocusCommand {
929 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
930 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
931 };
932 let err = cmd.execute().await.unwrap_err();
933 assert!(
934 err.to_string().contains("cannot resolve worktree path"),
935 "{err}"
936 );
937 }
938
939 #[tokio::test]
940 async fn focus_sends_the_open_op_for_an_existing_folder() {
941 let (_dir, sock, server) =
945 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
946 let target = tempfile::tempdir().unwrap();
947 let cmd = WorktreesCommand {
948 command: WorktreesSubcommands::Focus(FocusCommand {
949 path: target.path().to_path_buf(),
950 socket: Some(sock),
951 }),
952 };
953 cmd.execute().await.unwrap();
954 server.await.unwrap();
955 }
956
957 #[test]
958 fn render_windows_handles_empty_replies() {
959 assert_eq!(
960 render_windows(&json!({ "windows": [] })),
961 "No open windows."
962 );
963 assert_eq!(render_windows(&json!({})), "No open windows.");
964 }
965
966 #[test]
967 fn render_windows_renders_rows() {
968 let result = json!({ "windows": [{
969 "key": "w1",
970 "repo": "omni-dev",
971 "branch": "issue-1011",
972 "ahead": 2,
973 "behind": 1,
974 "folders": ["/home/me/omni-dev", "/home/me/docs"],
975 "last_seen": "2000-01-01T00:00:00Z",
976 }]});
977 let table = render_windows(&result);
978 assert!(table.contains("omni-dev"), "{table}");
979 assert!(table.contains("issue-1011"), "{table}");
981 assert!(table.contains("+2 -1"), "{table}");
982 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
984 assert_eq!(table.lines().count(), 2, "{table}");
986 }
987
988 #[test]
989 fn render_windows_prefers_main_repo_over_companion_repo() {
990 let result = json!({ "windows": [{
994 "key": "w1",
995 "repo": "issue-1250",
996 "main_repo": "omni-dev",
997 "branch": "issue-1250",
998 "folders": ["/home/me/worktrees/issue-1250"],
999 "last_seen": "2000-01-01T00:00:00Z",
1000 }]});
1001 let table = render_windows(&result);
1002 assert!(table.contains("omni-dev"), "{table}");
1003 let data_row = table.lines().nth(1).unwrap();
1006 assert!(data_row.starts_with("omni-dev"), "{data_row}");
1007 }
1008
1009 #[test]
1010 fn repo_name_falls_back_to_companion_repo_then_dash() {
1011 assert_eq!(
1012 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
1013 "omni-dev"
1014 );
1015 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
1016 assert_eq!(repo_name(&json!({})), "-");
1017 }
1018
1019 #[test]
1020 fn render_windows_strips_control_bytes() {
1021 let result = json!({ "windows": [{
1024 "key": "w1",
1025 "repo": "evil\x1b[31mrepo",
1026 "branch": "br\ranch\x07\u{9b}2J",
1027 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
1028 "last_seen": "2000-01-01T00:00:00Z",
1029 }]});
1030 let table = render_windows(&result);
1031 assert!(
1032 !table.contains(|c: char| c.is_control() && c != '\n'),
1033 "{table:?}"
1034 );
1035 assert!(table.contains("evil[31mrepo"), "{table:?}");
1037 assert!(table.contains("branch2J"), "{table:?}");
1038 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
1039 assert_eq!(table.lines().count(), 2, "{table:?}");
1041 }
1042
1043 #[test]
1044 fn sync_summary_formats_or_dashes() {
1045 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
1046 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
1047 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
1049 assert_eq!(sync_summary(&json!({})), "-");
1050 }
1051
1052 #[test]
1053 fn folder_summary_strips_control_bytes() {
1054 assert_eq!(
1055 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
1056 "/a[2J/b"
1057 );
1058 }
1059
1060 #[test]
1061 fn folder_summary_counts_extra_folders() {
1062 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
1063 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
1064 assert_eq!(
1065 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
1066 "/a (+2)"
1067 );
1068 }
1069
1070 #[test]
1071 fn age_secs_handles_absent_and_unparseable_and_past() {
1072 assert_eq!(age_secs(None), 0);
1073 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
1074 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
1075 }
1076
1077 #[test]
1078 fn render_tree_handles_empty_replies() {
1079 assert_eq!(
1080 render_tree(&json!({ "repos": [] })),
1081 "No repositories open."
1082 );
1083 assert_eq!(render_tree(&json!({})), "No repositories open.");
1084 }
1085
1086 #[test]
1087 fn worktree_paths_collects_every_worktree_in_render_order() {
1088 let result = json!({ "repos": [
1089 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
1091 { "worktrees": [ { "path": "/c" } ] },
1092 ]});
1093 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
1094 assert!(worktree_paths(&json!({})).is_empty());
1096 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
1097 }
1098
1099 #[test]
1100 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
1101 let mut result = json!({ "repos": [{ "worktrees": [
1105 { "path": "/a", "branch": "main" },
1106 { "path": "/b", "branch": "feature" },
1107 ]}]});
1108 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
1109 merge_ahead_behind(&mut result, results.as_object().unwrap());
1110
1111 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1112 let a = &worktrees[0];
1113 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
1114 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
1115 assert_eq!(sync_summary(a), "+2 -1");
1117 let b = &worktrees[1];
1118 assert!(b.get("ahead").is_none(), "{b:?}");
1119 assert!(b.get("behind").is_none(), "{b:?}");
1120 assert_eq!(sync_summary(b), "-");
1121 }
1122
1123 #[test]
1124 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
1125 let mut result = json!({ "repos": [{ "worktrees": [
1129 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
1133 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
1135
1136 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1137 assert_eq!(worktrees[0], json!("not-an-object"));
1139 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
1141 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
1143 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
1144 }
1145
1146 #[tokio::test]
1147 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
1148 let mut result = json!({ "repos": [] });
1151 let before = result.clone();
1152 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1153 assert_eq!(result, before);
1154 }
1155
1156 #[tokio::test]
1157 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
1158 let mut result =
1161 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1162 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1163 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1164 assert!(wt.get("ahead").is_none(), "{wt:?}");
1165 assert!(wt.get("behind").is_none(), "{wt:?}");
1166 }
1167
1168 fn fake_daemon_reply(
1173 reply: Value,
1174 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
1175 use futures::{SinkExt, StreamExt};
1176 use tokio::net::UnixListener;
1177 use tokio_util::codec::{Framed, LinesCodec};
1178
1179 let dir = tempfile::tempdir_in("/tmp").unwrap();
1181 let sock = dir.path().join("d.sock");
1182 let listener = UnixListener::bind(&sock).unwrap();
1183 let server = tokio::spawn(async move {
1184 let (stream, _) = listener.accept().await.unwrap();
1185 let mut framed = Framed::new(stream, LinesCodec::new());
1186 let _req = framed.next().await.unwrap().unwrap();
1187 framed
1188 .send(serde_json::to_string(&reply).unwrap())
1189 .await
1190 .unwrap();
1191 });
1192 (dir, sock, server)
1193 }
1194
1195 #[tokio::test]
1196 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
1197 let (_dir, sock, server) = fake_daemon_reply(
1198 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
1199 );
1200 let mut result =
1201 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1202 enrich_ahead_behind(&sock, &mut result).await;
1203 server.await.unwrap();
1204
1205 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1206 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
1207 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
1208 }
1209
1210 #[tokio::test]
1211 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
1212 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
1215 let mut result =
1216 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1217 enrich_ahead_behind(&sock, &mut result).await;
1218 server.await.unwrap();
1219
1220 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1221 assert!(wt.get("ahead").is_none(), "{wt:?}");
1222 assert!(wt.get("behind").is_none(), "{wt:?}");
1223 }
1224
1225 #[test]
1226 fn render_tree_groups_repos_and_worktrees() {
1227 let result = json!({ "repos": [{
1228 "main_repo": "omni-dev",
1229 "github": { "owner": "rust-works", "name": "omni-dev" },
1230 "root": "/home/me/omni-dev",
1231 "worktrees": [
1232 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
1233 "is_main": true, "open": true, "window_key": "w1" },
1234 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
1235 "is_main": false, "open": false },
1236 ],
1237 }]});
1238 let out = render_tree(&result);
1239 let header = out.lines().next().unwrap();
1241 assert!(header.contains("omni-dev"), "{out}");
1242 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
1243 assert!(header.contains("/home/me/omni-dev"), "{out}");
1244 assert!(
1246 out.lines()
1247 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
1248 "{out}"
1249 );
1250 let linked = out
1252 .lines()
1253 .find(|l| l.contains("issue-1300"))
1254 .unwrap_or_default();
1255 assert!(!linked.contains('*'), "{linked}");
1256 assert!(!linked.contains("open"), "{linked}");
1257 assert!(linked.contains("+1 -3"), "{linked}");
1258 assert_eq!(out.lines().count(), 3, "{out}");
1260 }
1261
1262 #[test]
1263 fn render_tree_separates_multiple_repos_with_blank_line() {
1264 let result = json!({ "repos": [
1265 {
1266 "main_repo": "alpha",
1267 "root": "/r/alpha",
1268 "worktrees": [
1269 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
1270 ],
1271 },
1272 {
1273 "main_repo": "beta",
1274 "root": "/r/beta",
1275 "worktrees": [
1276 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
1277 ],
1278 },
1279 ]});
1280 let out = render_tree(&result);
1281 assert!(
1283 out.contains("\n\nbeta"),
1284 "repos not blank-separated: {out:?}"
1285 );
1286 let alpha = out.find("alpha").unwrap();
1287 let beta = out.find("beta").unwrap();
1288 assert!(alpha < beta, "repo order not preserved: {out}");
1289 assert_eq!(out.lines().count(), 5, "{out:?}");
1290 }
1291
1292 #[test]
1293 fn render_tree_omits_github_for_non_github_repo() {
1294 let result = json!({ "repos": [{
1295 "main_repo": "internal",
1296 "root": "/srv/internal",
1297 "worktrees": [
1298 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
1299 ],
1300 }]});
1301 let out = render_tree(&result);
1302 assert!(!out.contains("github:"), "{out}");
1303 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
1304 }
1305
1306 #[test]
1307 fn render_tree_strips_control_bytes() {
1308 let result = json!({ "repos": [{
1311 "main_repo": "evil\x1b[31mrepo",
1312 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
1313 "root": "/tmp/r\x1b]0;x\x07oot",
1314 "worktrees": [
1315 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
1316 ],
1317 }]});
1318 let out = render_tree(&result);
1319 assert!(
1320 !out.contains(|c: char| c.is_control() && c != '\n'),
1321 "{out:?}"
1322 );
1323 assert_eq!(out.lines().count(), 2, "{out:?}");
1325 }
1326
1327 #[test]
1328 fn github_summary_needs_both_owner_and_name() {
1329 assert_eq!(
1330 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
1331 Some("github: o/n")
1332 );
1333 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
1334 assert_eq!(github_summary(&json!({})), None);
1335 }
1336
1337 #[test]
1338 fn reply_payload_unwraps_ok_and_maps_errors() {
1339 assert_eq!(
1341 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
1342 json!({ "a": 1 })
1343 );
1344 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
1346 assert!(err.to_string().contains("boom"), "{err}");
1347 let err = reply_payload(DaemonReply {
1349 ok: false,
1350 payload: Value::Null,
1351 error: None,
1352 })
1353 .unwrap_err();
1354 assert!(err.to_string().contains("unknown error"), "{err}");
1355 }
1356
1357 #[test]
1360 fn new_subcommands_route_and_require_their_args() {
1361 assert!(matches!(
1362 parse(&["close", "/home/me/wt"]),
1363 WorktreesSubcommands::Close(_)
1364 ));
1365 assert!(matches!(
1366 parse(&["show-closed"]),
1367 WorktreesSubcommands::ShowClosed(_)
1368 ));
1369 assert!(matches!(
1370 parse(&["register", "--key", "w1"]),
1371 WorktreesSubcommands::Register(_)
1372 ));
1373 assert!(matches!(
1374 parse(&["heartbeat", "--key", "w1"]),
1375 WorktreesSubcommands::Heartbeat(_)
1376 ));
1377 assert!(matches!(
1378 parse(&["unregister", "--key", "w1"]),
1379 WorktreesSubcommands::Unregister(_)
1380 ));
1381
1382 assert!(CloseCommand::try_parse_from(["close"]).is_err());
1384 assert!(RegisterCommand::try_parse_from(["register"]).is_err());
1385 assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
1386 assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
1387 }
1388
1389 #[test]
1390 fn close_parses_flags() {
1391 let cmd = CloseCommand::try_parse_from([
1392 "close",
1393 "/home/me/wt",
1394 "--window-only",
1395 "--dry-run",
1396 "-y",
1397 "--socket",
1398 "/tmp/d.sock",
1399 ])
1400 .unwrap();
1401 assert_eq!(cmd.path, Path::new("/home/me/wt"));
1402 assert!(cmd.window_only && cmd.dry_run && cmd.yes);
1403 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1404
1405 let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
1407 assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
1408 }
1409
1410 #[test]
1411 fn tree_follow_flag_parses() {
1412 let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
1413 assert!(cmd.follow);
1414 let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
1415 assert!(cmd.follow);
1416 assert_eq!(cmd.output, TableOrJson::Json);
1417 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1418 assert!(!cmd.follow);
1419 }
1420
1421 #[test]
1422 fn show_closed_parses_optional_bool() {
1423 assert!(ShowClosedCommand::try_parse_from(["show-closed"])
1424 .unwrap()
1425 .value
1426 .is_none());
1427 assert_eq!(
1428 ShowClosedCommand::try_parse_from(["show-closed", "false"])
1429 .unwrap()
1430 .value,
1431 Some(false)
1432 );
1433 assert_eq!(
1434 ShowClosedCommand::try_parse_from(["show-closed", "true"])
1435 .unwrap()
1436 .value,
1437 Some(true)
1438 );
1439 assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
1441 }
1442
1443 #[test]
1444 fn register_collects_repeated_folders() {
1445 let cmd = RegisterCommand::try_parse_from([
1446 "register", "--key", "w1", "--folder", "/a", "--folder", "/b", "--repo", "r", "--pid",
1447 "42",
1448 ])
1449 .unwrap();
1450 assert_eq!(cmd.key, "w1");
1451 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
1452 assert_eq!(cmd.repo.as_deref(), Some("r"));
1453 assert_eq!(cmd.pid, Some(42));
1454 }
1455
1456 #[test]
1457 fn answer_is_yes_accepts_only_affirmatives() {
1458 for yes in ["y", "Y", "yes", "YES", " yes \n"] {
1459 assert!(answer_is_yes(yes), "{yes:?}");
1460 }
1461 for no in ["", "n", "no", "nope", "true", "\n"] {
1462 assert!(!answer_is_yes(no), "{no:?}");
1463 }
1464 }
1465
1466 #[test]
1467 fn confirm_prompt_mentions_risks_only_when_present() {
1468 assert!(confirm_prompt(true).contains("risks"));
1472 assert!(!confirm_prompt(false).contains("risks"));
1473 assert!(confirm_prompt(true).contains("[y/N]"));
1474 assert!(confirm_prompt(false).contains("[y/N]"));
1475 }
1476
1477 #[test]
1478 fn read_line_from_maps_input_and_eof() {
1479 use std::io::Cursor;
1480 assert_eq!(
1484 read_line_from(&mut Cursor::new("y\n")).as_deref(),
1485 Some("y\n")
1486 );
1487 assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
1488 assert_eq!(
1489 read_line_from(&mut Cursor::new("no-newline")).as_deref(),
1490 Some("no-newline")
1491 );
1492 }
1493
1494 #[test]
1495 fn render_safety_report_renders_fields_and_notes() {
1496 let report = json!({
1497 "removable": true,
1498 "is_main": false,
1499 "open": true,
1500 "window_key": "w1",
1501 "window_folder_count": 2,
1502 "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
1503 "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
1504 });
1505 let out = render_safety_report(Path::new("/home/me/wt"), &report);
1506 assert!(out.contains("/home/me/wt"), "{out}");
1507 assert!(out.contains("removable: true"), "{out}");
1508 assert!(
1509 out.contains("open in a window: yes (key w1, 2 folder(s))"),
1510 "{out}"
1511 );
1512 assert!(out.contains("[dirty] uncommitted changes"), "{out}");
1513 assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
1514 }
1515
1516 #[test]
1517 fn render_safety_report_handles_no_window_and_no_notes() {
1518 let report = json!({ "removable": false, "is_main": true, "open": false });
1519 let out = render_safety_report(Path::new("/r"), &report);
1520 assert!(out.contains("removable: false"), "{out}");
1521 assert!(out.contains("main working tree: true"), "{out}");
1522 assert!(out.contains("open in a window: no"), "{out}");
1523 assert!(!out.contains("risks:"), "{out}");
1525 assert!(!out.contains("info:"), "{out}");
1526 }
1527
1528 #[test]
1529 fn render_safety_report_strips_control_bytes() {
1530 let report = json!({
1533 "removable": true, "is_main": false, "open": true,
1534 "window_key": "w\x1b[31m1", "window_folder_count": 1,
1535 "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
1536 "info": [],
1537 });
1538 let out = render_safety_report(Path::new("/r"), &report);
1539 assert!(
1540 !out.contains(|c: char| c.is_control() && c != '\n'),
1541 "{out:?}"
1542 );
1543 }
1544
1545 fn fake_daemon_seq(
1551 replies: Vec<Value>,
1552 ) -> (
1553 tempfile::TempDir,
1554 PathBuf,
1555 tokio::task::JoinHandle<Vec<Value>>,
1556 ) {
1557 use futures::{SinkExt, StreamExt};
1558 use tokio::net::UnixListener;
1559 use tokio_util::codec::{Framed, LinesCodec};
1560
1561 let dir = tempfile::tempdir_in("/tmp").unwrap();
1562 let sock = dir.path().join("d.sock");
1563 let listener = UnixListener::bind(&sock).unwrap();
1564 let server = tokio::spawn(async move {
1565 let mut requests = Vec::new();
1566 for reply in replies {
1567 let (stream, _) = listener.accept().await.unwrap();
1568 let mut framed = Framed::new(stream, LinesCodec::new());
1569 let req = framed.next().await.unwrap().unwrap();
1570 requests.push(serde_json::from_str::<Value>(&req).unwrap());
1571 framed
1572 .send(serde_json::to_string(&reply).unwrap())
1573 .await
1574 .unwrap();
1575 }
1576 requests
1577 });
1578 (dir, sock, server)
1579 }
1580
1581 #[tokio::test]
1582 async fn close_window_only_sends_remove_false() {
1583 let (_dir, sock, server) =
1584 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
1585 let target = tempfile::tempdir().unwrap();
1586 CloseCommand {
1587 path: target.path().to_path_buf(),
1588 window_only: true,
1589 dry_run: false,
1590 yes: false,
1591 socket: Some(sock),
1592 }
1593 .execute()
1594 .await
1595 .unwrap();
1596 let reqs = server.await.unwrap();
1597 assert_eq!(reqs.len(), 1);
1600 assert_eq!(reqs[0]["op"], "close");
1601 assert_eq!(reqs[0]["payload"]["remove"], json!(false));
1602 assert!(
1603 reqs[0]["payload"].get("confirmed").is_none(),
1604 "{:?}",
1605 reqs[0]
1606 );
1607 let want = std::fs::canonicalize(target.path()).unwrap();
1609 assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
1610 }
1611
1612 #[tokio::test]
1613 async fn close_window_only_dry_run_never_contacts_the_daemon() {
1614 let target = tempfile::tempdir().unwrap();
1617 CloseCommand {
1618 path: target.path().to_path_buf(),
1619 window_only: true,
1620 dry_run: true,
1621 yes: false,
1622 socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
1623 }
1624 .execute()
1625 .await
1626 .unwrap();
1627 }
1628
1629 #[tokio::test]
1630 async fn close_dry_run_only_runs_phase_one() {
1631 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1633 "ok": true,
1634 "payload": { "removable": true, "is_main": false, "open": false,
1635 "window_folder_count": 0, "risks": [], "info": [] }
1636 })]);
1637 let target = tempfile::tempdir().unwrap();
1638 CloseCommand {
1639 path: target.path().to_path_buf(),
1640 window_only: false,
1641 dry_run: true,
1642 yes: false,
1643 socket: Some(sock),
1644 }
1645 .execute()
1646 .await
1647 .unwrap();
1648 let reqs = server.await.unwrap();
1649 assert_eq!(reqs.len(), 1);
1651 assert_eq!(reqs[0]["op"], "close");
1652 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
1653 assert!(
1654 reqs[0]["payload"].get("confirmed").is_none(),
1655 "{:?}",
1656 reqs[0]
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn close_yes_executes_phase_two() {
1662 let (_dir, sock, server) = fake_daemon_seq(vec![
1664 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
1665 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
1666 json!({ "ok": true, "payload": { "removed": true } }),
1667 ]);
1668 let target = tempfile::tempdir().unwrap();
1669 CloseCommand {
1670 path: target.path().to_path_buf(),
1671 window_only: false,
1672 dry_run: false,
1673 yes: true,
1674 socket: Some(sock),
1675 }
1676 .execute()
1677 .await
1678 .unwrap();
1679 let reqs = server.await.unwrap();
1680 assert_eq!(reqs.len(), 2);
1682 assert_eq!(reqs[0]["op"], "close");
1683 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
1684 assert!(
1685 reqs[0]["payload"].get("confirmed").is_none(),
1686 "{:?}",
1687 reqs[0]
1688 );
1689 assert_eq!(reqs[1]["op"], "close");
1690 assert_eq!(reqs[1]["payload"]["remove"], json!(true));
1691 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
1692 assert!(
1694 reqs[1]["payload"].get("requester_key").is_none(),
1695 "{:?}",
1696 reqs[1]
1697 );
1698 }
1699
1700 #[tokio::test]
1701 async fn close_refuses_a_non_removable_target() {
1702 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1705 "ok": true,
1706 "payload": { "removable": false, "is_main": true, "open": false,
1707 "window_folder_count": 0, "risks": [], "info": [] }
1708 })]);
1709 let target = tempfile::tempdir().unwrap();
1710 let err = CloseCommand {
1711 path: target.path().to_path_buf(),
1712 window_only: false,
1713 dry_run: false,
1714 yes: true,
1715 socket: Some(sock),
1716 }
1717 .execute()
1718 .await
1719 .unwrap_err();
1720 assert!(
1721 err.to_string().contains("not a removable worktree"),
1722 "{err}"
1723 );
1724 assert_eq!(server.await.unwrap().len(), 1);
1726 }
1727
1728 #[tokio::test]
1729 async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
1730 let err = CloseCommand {
1731 path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
1732 window_only: false,
1733 dry_run: false,
1734 yes: true,
1735 socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
1736 }
1737 .execute()
1738 .await
1739 .unwrap_err();
1740 assert!(
1741 err.to_string().contains("cannot resolve worktree path"),
1742 "{err}"
1743 );
1744 }
1745
1746 #[tokio::test]
1747 async fn show_closed_sets_and_reads() {
1748 let (_dir, sock, server) =
1750 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1751 ShowClosedCommand {
1752 value: Some(false),
1753 socket: Some(sock),
1754 }
1755 .execute()
1756 .await
1757 .unwrap();
1758 let reqs = server.await.unwrap();
1759 assert_eq!(reqs[0]["op"], "set-show-closed");
1760 assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
1761
1762 let (_dir, sock, server) = fake_daemon_seq(vec![
1764 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
1765 ]);
1766 ShowClosedCommand {
1767 value: None,
1768 socket: Some(sock),
1769 }
1770 .execute()
1771 .await
1772 .unwrap();
1773 assert_eq!(server.await.unwrap()[0]["op"], "tree");
1775 }
1776
1777 #[tokio::test]
1778 async fn register_heartbeat_unregister_send_their_ops() {
1779 let (_dir, sock, server) =
1780 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1781 RegisterCommand {
1782 key: "w1".to_string(),
1783 folders: vec![PathBuf::from("/a")],
1784 repo: Some("r".to_string()),
1785 title: None,
1786 pid: Some(7),
1787 socket: Some(sock),
1788 }
1789 .execute()
1790 .await
1791 .unwrap();
1792 let reqs = server.await.unwrap();
1793 assert_eq!(reqs[0]["op"], "register");
1795 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1796 assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
1797 assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
1798 assert_eq!(reqs[0]["payload"]["pid"], json!(7));
1799
1800 let (_dir, sock, server) = fake_daemon_seq(vec![
1801 json!({ "ok": true, "payload": { "known": true, "close": true } }),
1802 ]);
1803 HeartbeatCommand {
1804 key: "w1".to_string(),
1805 socket: Some(sock),
1806 }
1807 .execute()
1808 .await
1809 .unwrap();
1810 let reqs = server.await.unwrap();
1811 assert_eq!(reqs[0]["op"], "heartbeat");
1812 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1813
1814 let (_dir, sock, server) =
1815 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
1816 UnregisterCommand {
1817 key: "w1".to_string(),
1818 socket: Some(sock),
1819 }
1820 .execute()
1821 .await
1822 .unwrap();
1823 let reqs = server.await.unwrap();
1824 assert_eq!(reqs[0]["op"], "unregister");
1825 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1826 }
1827
1828 #[tokio::test]
1829 async fn tree_follow_renders_each_pushed_frame() {
1830 use crate::daemon::testutil::fake_daemon_stream;
1831
1832 let (_dir, sock, server) = fake_daemon_stream(vec![
1834 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1835 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
1836 ]);
1837 follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
1838 server.await.unwrap();
1839
1840 let (_dir, sock, server) = fake_daemon_stream(vec![
1843 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1844 ]);
1845 follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
1846 server.await.unwrap();
1847
1848 let (_dir, sock, server) = fake_daemon_stream(vec![
1851 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1852 ]);
1853 TreeCommand {
1854 socket: Some(sock),
1855 output: TableOrJson::Json,
1856 follow: true,
1857 }
1858 .execute()
1859 .await
1860 .unwrap();
1861 server.await.unwrap();
1862 }
1863
1864 #[tokio::test]
1865 async fn worktrees_command_routes_each_new_subcommand() {
1866 let target = tempfile::tempdir().unwrap();
1870 WorktreesCommand {
1872 command: WorktreesSubcommands::Close(CloseCommand {
1873 path: target.path().to_path_buf(),
1874 window_only: true,
1875 dry_run: true,
1876 yes: false,
1877 socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
1878 }),
1879 }
1880 .execute()
1881 .await
1882 .unwrap();
1883
1884 let (_d, sock, server) =
1886 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1887 WorktreesCommand {
1888 command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
1889 value: Some(true),
1890 socket: Some(sock),
1891 }),
1892 }
1893 .execute()
1894 .await
1895 .unwrap();
1896 server.await.unwrap();
1897
1898 let (_d, sock, server) =
1900 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1901 WorktreesCommand {
1902 command: WorktreesSubcommands::Register(RegisterCommand {
1903 key: "w1".to_string(),
1904 folders: vec![],
1905 repo: None,
1906 title: None,
1907 pid: None,
1908 socket: Some(sock),
1909 }),
1910 }
1911 .execute()
1912 .await
1913 .unwrap();
1914 server.await.unwrap();
1915
1916 let (_d, sock, server) =
1918 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
1919 WorktreesCommand {
1920 command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
1921 key: "w1".to_string(),
1922 socket: Some(sock),
1923 }),
1924 }
1925 .execute()
1926 .await
1927 .unwrap();
1928 server.await.unwrap();
1929
1930 let (_d, sock, server) =
1932 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
1933 WorktreesCommand {
1934 command: WorktreesSubcommands::Unregister(UnregisterCommand {
1935 key: "w1".to_string(),
1936 socket: Some(sock),
1937 }),
1938 }
1939 .execute()
1940 .await
1941 .unwrap();
1942 server.await.unwrap();
1943 }
1944
1945 #[tokio::test]
1946 async fn close_aborts_when_confirmation_is_declined() {
1947 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1951 "ok": true,
1952 "payload": { "removable": true, "is_main": false, "open": false,
1953 "window_folder_count": 0, "risks": [], "info": [] }
1954 })]);
1955 let target = tempfile::tempdir().unwrap();
1956 CloseCommand {
1957 path: target.path().to_path_buf(),
1958 window_only: false,
1959 dry_run: false,
1960 yes: false,
1961 socket: Some(sock),
1962 }
1963 .execute_with(|_has_risks| async { false })
1964 .await
1965 .unwrap();
1966 assert_eq!(server.await.unwrap().len(), 1);
1967 }
1968
1969 #[tokio::test]
1970 async fn close_deletes_when_confirmation_is_accepted() {
1971 let (_dir, sock, server) = fake_daemon_seq(vec![
1974 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
1975 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
1976 json!({ "ok": true, "payload": { "removed": true } }),
1977 ]);
1978 let target = tempfile::tempdir().unwrap();
1979 CloseCommand {
1980 path: target.path().to_path_buf(),
1981 window_only: false,
1982 dry_run: false,
1983 yes: false,
1984 socket: Some(sock),
1985 }
1986 .execute_with(|_has_risks| async { true })
1987 .await
1988 .unwrap();
1989 let reqs = server.await.unwrap();
1990 assert_eq!(reqs.len(), 2);
1991 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
1992 }
1993
1994 #[tokio::test]
1995 async fn confirm_removal_with_decides_from_the_answer() {
1996 assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
1999 assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
2000 assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
2001 assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
2002 assert!(!confirm_removal_with(false, async { None }).await);
2003 }
2004}