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;
23use crate::git::worktree_rebase::{
24 self, FetchOutcome, RebaseOptions, RebaseResult, Selection, SkipReason, WorktreeOutcome,
25};
26
27const SERVICE: &str = "worktrees";
29
30#[derive(Parser)]
33pub struct WorktreesCommand {
34 #[command(subcommand)]
36 pub command: WorktreesSubcommands,
37}
38
39#[derive(Subcommand)]
41pub enum WorktreesSubcommands {
42 List(ListCommand),
44 Tree(TreeCommand),
46 Focus(FocusCommand),
48 Close(CloseCommand),
50 Rebase(RebaseCommand),
52 MergeQueue(MergeQueueCommand),
54 Reposition(RepositionCommand),
56 Reload(ReloadCommand),
58 ShowClosed(ShowClosedCommand),
60 Register(RegisterCommand),
62 Heartbeat(HeartbeatCommand),
64 Unregister(UnregisterCommand),
66}
67
68impl WorktreesCommand {
69 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
76 match self.command {
77 WorktreesSubcommands::List(cmd) => cmd.execute().await,
78 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
79 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
80 WorktreesSubcommands::Close(cmd) => cmd.execute().await,
81 WorktreesSubcommands::Rebase(cmd) => cmd.execute(repo).await,
82 WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
83 WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
84 WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
85 WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
86 WorktreesSubcommands::Register(cmd) => cmd.execute().await,
87 WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
88 WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
89 }
90 }
91}
92
93#[derive(Parser)]
95pub struct ListCommand {
96 #[arg(long, value_name = "PATH")]
98 pub socket: Option<PathBuf>,
99 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
101 pub output: TableOrJson,
102 #[arg(long, hide = true)]
104 pub json: bool,
105}
106
107impl ListCommand {
108 pub async fn execute(mut self) -> Result<()> {
110 if self.json {
111 eprintln!("warning: --json is deprecated; use -o/--output json instead");
112 self.output = TableOrJson::Json;
113 }
114 let socket = server::resolve_socket(self.socket)?;
115 let result = call(&socket, "list", Value::Null).await?;
116 match self.output {
117 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
118 TableOrJson::Table => println!("{}", render_windows(&result)),
119 }
120 Ok(())
121 }
122}
123
124#[derive(Parser)]
128pub struct TreeCommand {
129 #[arg(long, value_name = "PATH")]
131 pub socket: Option<PathBuf>,
132 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
134 pub output: TableOrJson,
135 #[arg(short = 'f', long)]
138 pub follow: bool,
139}
140
141impl TreeCommand {
142 pub async fn execute(self) -> Result<()> {
144 let socket = server::resolve_socket(self.socket)?;
145 if self.follow {
146 return follow_tree_stream(&socket, self.output).await;
147 }
148 let mut result = call(&socket, "tree", Value::Null).await?;
149 enrich_ahead_behind(&socket, &mut result).await;
155 match self.output {
156 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
157 TableOrJson::Table => println!("{}", render_tree(&result)),
158 }
159 Ok(())
160 }
161}
162
163async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
170 let mut sub = DaemonClient::new(socket)
171 .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
172 .await?;
173 loop {
174 tokio::select! {
175 frame = sub.next() => {
176 let Some(frame) = frame else { break };
178 let mut payload = reply_payload(frame?)?;
179 enrich_ahead_behind(socket, &mut payload).await;
183 match output {
184 TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
186 TableOrJson::Table => println!("{}", render_tree(&payload)),
187 }
188 }
189 _ = tokio::signal::ctrl_c() => break,
192 }
193 }
194 Ok(())
195}
196
197#[derive(Parser)]
204pub struct FocusCommand {
205 #[arg(value_name = "PATH")]
207 pub path: PathBuf,
208 #[arg(long, value_name = "PATH")]
210 pub socket: Option<PathBuf>,
211}
212
213impl FocusCommand {
214 pub async fn execute(self) -> Result<()> {
216 let path = std::fs::canonicalize(&self.path)
220 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
221 let socket = server::resolve_socket(self.socket)?;
222 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
223 println!("Focused {}", path.display());
224 Ok(())
225 }
226}
227
228#[derive(Parser)]
237pub struct CloseCommand {
238 #[arg(value_name = "PATH")]
241 pub path: PathBuf,
242 #[arg(long)]
244 pub window_only: bool,
245 #[arg(long)]
247 pub dry_run: bool,
248 #[arg(short = 'y', long)]
250 pub yes: bool,
251 #[arg(long, value_name = "PATH")]
253 pub socket: Option<PathBuf>,
254}
255
256impl CloseCommand {
257 pub async fn execute(self) -> Result<()> {
259 self.execute_with(confirm_removal).await
260 }
261
262 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
267 where
268 F: FnOnce(bool) -> Fut,
269 Fut: std::future::Future<Output = bool>,
270 {
271 let path = std::fs::canonicalize(&self.path)
274 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
275 let path_str = path.to_string_lossy().to_string();
276 let socket = server::resolve_socket(self.socket)?;
277
278 if self.window_only {
282 if self.dry_run {
283 println!(
284 "Would close the window for {} (dry run; nothing closed)",
285 path.display()
286 );
287 return Ok(());
288 }
289 call(
290 &socket,
291 "close",
292 json!({ "path": path_str, "remove": false }),
293 )
294 .await?;
295 println!("Closed the window for {}", path.display());
296 return Ok(());
297 }
298
299 let report = call(
301 &socket,
302 "close",
303 json!({ "path": path_str, "remove": true }),
304 )
305 .await?;
306 println!("{}", render_safety_report(&path, &report));
307
308 if self.dry_run {
309 return Ok(());
310 }
311 if report.get("removable").and_then(Value::as_bool) != Some(true) {
314 bail!(
315 "{} is not a removable worktree (nothing deleted); \
316 use --window-only to just close its window",
317 path.display()
318 );
319 }
320 let has_risks = report
321 .get("risks")
322 .and_then(Value::as_array)
323 .is_some_and(|r| !r.is_empty());
324 if !self.yes && !confirm(has_risks).await {
325 println!("Aborted; nothing was deleted.");
326 return Ok(());
327 }
328
329 call(
331 &socket,
332 "close",
333 json!({ "path": path_str, "remove": true, "confirmed": true }),
334 )
335 .await?;
336 println!("Deleted worktree {}", path.display());
337 Ok(())
338 }
339}
340
341#[derive(Parser)]
355pub struct RebaseCommand {
356 #[arg(value_name = "PATH")]
359 pub paths: Vec<PathBuf>,
360 #[arg(long)]
363 pub all: bool,
364 #[arg(long, value_name = "REF")]
367 pub onto: Option<String>,
368 #[arg(long)]
371 pub autostash: bool,
372 #[arg(long)]
374 pub dry_run: bool,
375 #[arg(long)]
378 pub keep_conflicts: bool,
379 #[arg(short = 'y', long)]
381 pub yes: bool,
382 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
384 pub output: TableOrJson,
385}
386
387impl RebaseCommand {
388 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
390 self.execute_with(repo, confirm_rebase).await
391 }
392
393 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
398 where
399 F: FnOnce(usize) -> Fut,
400 Fut: std::future::Future<Output = bool>,
401 {
402 let selection = self.selection(repo)?;
403 let opts = RebaseOptions {
404 onto: self.onto.clone(),
405 autostash: self.autostash,
406 dry_run: self.dry_run,
407 keep_conflicts: self.keep_conflicts,
408 git_bin: None,
412 };
413
414 let plan_opts = opts.clone();
417 let plan =
418 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
419 .await
420 .context("rebase planning task panicked")??;
421
422 let json = matches!(self.output, TableOrJson::Json);
423 if self.dry_run || !plan.has_pending_rebases() {
427 self.print(json, &plan.fetches, &plan.worktrees)?;
428 return Ok(());
429 }
430
431 if !json {
433 println!("{}", render_fetches(&plan.fetches));
434 println!("{}", render_outcomes(&plan.worktrees));
435 }
436 let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
437 if !self.yes && !confirm(pending).await {
438 println!("Aborted; no worktree was rebased.");
439 return Ok(());
440 }
441
442 let fetches = plan.fetches.clone();
443 let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
444 .await
445 .context("rebase task panicked")?;
446 if !json {
447 println!();
448 }
449 self.print(json, &fetches, &outcomes)
450 }
451
452 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
460 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
461 if self.all {
462 if !self.paths.is_empty() {
463 bail!("pass either <PATH>... or --all, not both");
464 }
465 return Ok(Selection::All { base });
466 }
467 if self.paths.is_empty() {
468 bail!(
469 "specify one or more <PATH> arguments, or --all to rebase \
470 every linked worktree of this repository"
471 );
472 }
473 let paths = self
474 .paths
475 .iter()
476 .map(|path| {
477 if path.is_absolute() {
478 path.clone()
479 } else {
480 base.join(path)
481 }
482 })
483 .collect();
484 Ok(Selection::Paths(paths))
485 }
486
487 fn print(
489 &self,
490 json: bool,
491 fetches: &[FetchOutcome],
492 outcomes: &[WorktreeOutcome],
493 ) -> Result<()> {
494 if json {
495 let value = json!({
496 "dry_run": self.dry_run,
497 "fetches": fetches,
498 "worktrees": outcomes,
499 });
500 println!("{}", serde_json::to_string_pretty(&value)?);
501 } else {
502 println!("{}", render_fetches(fetches));
503 println!("{}", render_outcomes(outcomes));
504 }
505 Ok(())
506 }
507}
508
509fn is_pending(outcome: &WorktreeOutcome) -> bool {
511 matches!(outcome.result, RebaseResult::WouldRebase { .. })
512}
513
514fn render_fetches(fetches: &[FetchOutcome]) -> String {
517 if fetches.is_empty() {
518 return "No repository selected.".to_string();
519 }
520 fetches
521 .iter()
522 .map(fetch_line)
523 .collect::<Vec<_>>()
524 .join("\n")
525}
526
527fn fetch_line(fetch: &FetchOutcome) -> String {
529 let root = sanitize(&fetch.repo_root.display().to_string());
530 let onto = sanitize(&fetch.onto);
531 if !fetch.fetched {
532 return format!("Using {onto} in {root} (local ref; nothing fetched)");
533 }
534 if fetch.ok {
535 format!("Fetched {onto} once for {root}")
536 } else {
537 let detail = brief(fetch.detail.as_deref().unwrap_or(""));
538 format!("Fetch of {onto} FAILED for {root}: {detail}")
539 }
540}
541
542fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
544 if outcomes.is_empty() {
545 return "No worktrees selected.".to_string();
546 }
547 let mut out = format!(
548 "{:<12} {:<24} {:<16} {}",
549 "STATUS", "BRANCH", "ONTO", "WORKTREE"
550 );
551 for outcome in outcomes {
552 out.push('\n');
553 out.push_str(&outcome_row(outcome));
554 }
555 out
556}
557
558fn outcome_row(outcome: &WorktreeOutcome) -> String {
560 let (status, detail) = status_and_detail(&outcome.result);
561 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
562 let onto = sanitize(&outcome.onto);
563 let path = sanitize(&outcome.path.display().to_string());
564 let suffix = if detail.is_empty() {
565 String::new()
566 } else {
567 format!(" ({detail})")
568 };
569 format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
570}
571
572fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
574 match result {
575 RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
576 RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
577 RebaseResult::UpToDate => ("up-to-date", String::new()),
578 RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
579 RebaseResult::Conflict {
583 detail,
584 left_in_place: true,
585 } => (
586 "conflict",
587 format!(
588 "left in place; resolve then `git rebase --continue`: {}",
589 brief(detail)
590 ),
591 ),
592 RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
593 RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
594 }
595}
596
597fn skip_reason_text(reason: SkipReason) -> &'static str {
599 match reason {
600 SkipReason::MainWorkingTree => "main working tree",
601 SkipReason::DetachedHead => "detached HEAD",
602 SkipReason::Dirty => "uncommitted changes; pass --autostash",
603 SkipReason::OperationInProgress => "a rebase/merge is already in progress",
604 SkipReason::NotAWorktree => "not a git worktree",
605 SkipReason::NoOntoRef => "could not resolve the target ref",
606 }
607}
608
609fn brief(detail: &str) -> String {
612 let first = detail
613 .lines()
614 .find(|line| !line.trim().is_empty())
615 .unwrap_or("");
616 let clean = sanitize(first.trim());
617 if clean.chars().count() > 100 {
618 let truncated: String = clean.chars().take(97).collect();
619 format!("{truncated}...")
620 } else {
621 clean
622 }
623}
624
625async fn confirm_rebase(pending: usize) -> bool {
627 confirm_rebase_with(pending, read_stdin_line()).await
628}
629
630async fn confirm_rebase_with(
634 pending: usize,
635 read: impl std::future::Future<Output = Option<String>>,
636) -> bool {
637 use std::io::Write;
638 eprint!("{}", rebase_prompt(pending));
639 let _ = std::io::stderr().flush();
640 read.await.as_deref().is_some_and(answer_is_yes)
641}
642
643fn rebase_prompt(pending: usize) -> String {
646 let noun = if pending == 1 {
647 "worktree"
648 } else {
649 "worktrees"
650 };
651 format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
652}
653
654#[derive(Parser)]
663pub struct MergeQueueCommand {
664 #[arg(value_name = "PATH", required = true)]
667 pub paths: Vec<PathBuf>,
668 #[arg(long)]
670 pub check: bool,
671 #[arg(short = 'y', long)]
673 pub yes: bool,
674 #[arg(long, value_name = "PATH")]
676 pub socket: Option<PathBuf>,
677}
678
679impl MergeQueueCommand {
680 pub async fn execute(self) -> Result<()> {
683 self.execute_with(confirm_enqueue).await
684 }
685
686 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
691 where
692 F: FnOnce(usize) -> Fut,
693 Fut: std::future::Future<Output = bool>,
694 {
695 let mut paths = Vec::with_capacity(self.paths.len());
698 for p in &self.paths {
699 let abs = std::fs::canonicalize(p)
700 .with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
701 paths.push(abs.to_string_lossy().to_string());
702 }
703 let socket = server::resolve_socket(self.socket)?;
704
705 let report = call(
707 &socket,
708 "merge-queue",
709 json!({ "paths": paths, "check": true }),
710 )
711 .await?;
712 println!("{}", render_eligibility_report(&report));
713
714 if self.check {
715 return Ok(());
716 }
717 let eligible = report
718 .get("eligible")
719 .and_then(Value::as_array)
720 .map_or(0, Vec::len);
721 if eligible == 0 {
722 println!("Nothing to enqueue.");
723 return Ok(());
724 }
725 if !self.yes && !confirm(eligible).await {
726 println!("Aborted; nothing was enqueued.");
727 return Ok(());
728 }
729
730 let result = call(
732 &socket,
733 "merge-queue",
734 json!({ "paths": paths, "confirmed": true }),
735 )
736 .await?;
737 println!("{}", render_enqueue_result(&result));
738 Ok(())
739 }
740}
741
742#[derive(Parser)]
755pub struct RepositionCommand {
756 #[arg(value_name = "PATH")]
759 pub paths: Vec<PathBuf>,
760 #[arg(
763 long,
764 value_name = "PATH",
765 required_unless_present = "undo",
766 conflicts_with = "undo"
767 )]
768 pub reference: Option<PathBuf>,
769 #[arg(long, conflicts_with = "undo")]
771 pub dry_run: bool,
772 #[arg(long)]
774 pub undo: bool,
775 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
777 pub output: TableOrJson,
778 #[arg(long, value_name = "PATH")]
780 pub socket: Option<PathBuf>,
781}
782
783impl RepositionCommand {
784 pub async fn execute(self) -> Result<()> {
786 let output = self.output;
787 let socket = server::resolve_socket(self.socket)?;
788 if self.undo {
789 let reply = call(&socket, "reposition-undo", Value::Null).await?;
790 return print_reposition(output, &reply);
791 }
792 let Some(reference) = self.reference.as_deref() else {
794 bail!("`reposition` requires `--reference <PATH>`");
795 };
796
797 let windows = call(&socket, "list", Value::Null).await?;
801 let reference_key = window_key_for(&windows, reference, "repositioned")?;
802 let mut target_keys = Vec::with_capacity(self.paths.len());
803 for path in &self.paths {
804 target_keys.push(window_key_for(&windows, path, "repositioned")?);
805 }
806
807 let reply = call(
808 &socket,
809 "reposition",
810 json!({
811 "reference_key": reference_key,
812 "target_keys": target_keys,
813 "check": self.dry_run,
814 }),
815 )
816 .await?;
817 print_reposition(output, &reply)
818 }
819}
820
821fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
823 match output {
824 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
825 TableOrJson::Table => println!("{}", render_reposition(reply)),
826 }
827 Ok(())
828}
829
830#[derive(Parser)]
845pub struct ReloadCommand {
846 #[arg(value_name = "PATH", required = true)]
850 pub paths: Vec<PathBuf>,
851 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
853 pub output: TableOrJson,
854 #[arg(long, value_name = "PATH")]
856 pub socket: Option<PathBuf>,
857}
858
859impl ReloadCommand {
860 pub async fn execute(self) -> Result<()> {
862 let socket = server::resolve_socket(self.socket)?;
863 let windows = call(&socket, "list", Value::Null).await?;
864 let mut target_keys = Vec::with_capacity(self.paths.len());
865 for path in &self.paths {
866 target_keys.push(window_key_for(&windows, path, "reloaded")?);
867 }
868
869 let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
870 match self.output {
871 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
872 TableOrJson::Table => println!("{}", render_reload(&reply)),
873 }
874 Ok(())
875 }
876}
877
878fn render_reload(reply: &Value) -> String {
885 let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
886 let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
887 let unknown: Vec<String> = reply
888 .get("unknown")
889 .and_then(Value::as_array)
890 .map(|keys| {
891 keys.iter()
892 .filter_map(Value::as_str)
893 .map(sanitize)
894 .collect()
895 })
896 .unwrap_or_default();
897
898 let noun = if requested == 1 { "window" } else { "windows" };
901 let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
902 if !unknown.is_empty() {
903 out.push_str(&format!(
906 "\nNo longer open, so not signalled: {}",
907 unknown.join(", ")
908 ));
909 }
910 out
911}
912
913fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
924 let wanted = std::fs::canonicalize(path)
925 .with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
926 windows
927 .get("windows")
928 .and_then(Value::as_array)
929 .map(Vec::as_slice)
930 .unwrap_or_default()
931 .iter()
932 .find(|window| {
933 window
934 .get("folders")
935 .and_then(Value::as_array)
936 .is_some_and(|folders| {
937 folders.iter().filter_map(Value::as_str).any(|folder| {
938 std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
939 })
940 })
941 })
942 .and_then(|window| window.get("key").and_then(Value::as_str))
943 .map(ToString::to_string)
944 .ok_or_else(|| {
945 anyhow::anyhow!(
946 "no VS Code window has {} open (only open windows can be {verb})",
947 wanted.display()
948 )
949 })
950}
951
952fn render_reposition(reply: &Value) -> String {
956 if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
957 return "omni-dev does not hold the macOS Accessibility permission, so no window \
958 was touched.\nGrant it in System Settings → Privacy & Security → \
959 Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
960 .to_string();
961 }
962 if let Some(blocked) = reply.get("blocked") {
963 let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
964 let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
965 return format!("Nothing was moved [{reason}]: {detail}");
966 }
967
968 let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
969 let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
970 let mut out = String::new();
971 if let Some(reference) = reply.get("reference") {
972 let title = sanitize(
973 reference
974 .get("title")
975 .and_then(Value::as_str)
976 .unwrap_or("-"),
977 );
978 out.push_str(&format!(
979 "Reference: {title} {}\n",
980 render_frame(reference.get("frame"))
981 ));
982 }
983 out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
984 let results = reply
985 .get("results")
986 .and_then(Value::as_array)
987 .map(Vec::as_slice)
988 .unwrap_or_default();
989 for result in results {
990 let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
991 let title = sanitize(
992 result
993 .get("title")
994 .and_then(Value::as_str)
995 .or_else(|| result.get("key").and_then(Value::as_str))
996 .unwrap_or("-"),
997 );
998 let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
999 out.push_str(&format!("\n {outcome}: {title} — {detail}"));
1000 }
1001 if results.is_empty() {
1002 out.push_str("\n (nothing to report)");
1003 }
1004 out
1005}
1006
1007fn render_frame(frame: Option<&Value>) -> String {
1009 let Some(frame) = frame else {
1010 return "-".to_string();
1011 };
1012 let field = |name: &str| {
1013 frame
1014 .get(name)
1015 .and_then(Value::as_f64)
1016 .unwrap_or(0.0)
1017 .round()
1018 };
1019 format!(
1020 "{}×{} at ({}, {})",
1021 field("width"),
1022 field("height"),
1023 field("x"),
1024 field("y")
1025 )
1026}
1027
1028#[derive(Parser)]
1034pub struct ShowClosedCommand {
1035 #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
1037 pub value: Option<bool>,
1038 #[arg(long, value_name = "PATH")]
1040 pub socket: Option<PathBuf>,
1041}
1042
1043impl ShowClosedCommand {
1044 pub async fn execute(self) -> Result<()> {
1046 let socket = server::resolve_socket(self.socket)?;
1047 if let Some(show_closed) = self.value {
1048 call(
1049 &socket,
1050 "set-show-closed",
1051 json!({ "show_closed": show_closed }),
1052 )
1053 .await?;
1054 println!("show-closed: {show_closed}");
1055 } else {
1056 let tree = call(&socket, "tree", Value::Null).await?;
1058 let current = tree
1059 .get("show_closed")
1060 .and_then(Value::as_bool)
1061 .unwrap_or(true);
1062 println!("show-closed: {current}");
1063 }
1064 Ok(())
1065 }
1066}
1067
1068#[derive(Parser)]
1074pub struct RegisterCommand {
1075 #[arg(long, value_name = "KEY")]
1077 pub key: String,
1078 #[arg(long = "folder", value_name = "PATH")]
1080 pub folders: Vec<PathBuf>,
1081 #[arg(long, value_name = "REPO")]
1089 pub repo_name: Option<String>,
1090 #[arg(long, value_name = "TITLE")]
1092 pub title: Option<String>,
1093 #[arg(long, value_name = "PID")]
1095 pub pid: Option<u32>,
1096 #[arg(long, value_name = "PATH")]
1098 pub socket: Option<PathBuf>,
1099}
1100
1101impl RegisterCommand {
1102 pub async fn execute(self) -> Result<()> {
1104 let socket = server::resolve_socket(self.socket)?;
1105 let payload = json!({
1106 "key": self.key,
1107 "folders": self.folders,
1108 "repo": self.repo_name,
1109 "title": self.title,
1110 "pid": self.pid,
1111 });
1112 call(&socket, "register", payload).await?;
1113 println!("Registered {}", self.key);
1114 Ok(())
1115 }
1116}
1117
1118#[derive(Parser)]
1125pub struct HeartbeatCommand {
1126 #[arg(long, value_name = "KEY")]
1128 pub key: String,
1129 #[arg(long, value_name = "PATH")]
1131 pub socket: Option<PathBuf>,
1132}
1133
1134impl HeartbeatCommand {
1135 pub async fn execute(self) -> Result<()> {
1137 let socket = server::resolve_socket(self.socket)?;
1138 let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
1139 let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
1140 let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
1143 let reload = reply
1144 .get("reload")
1145 .and_then(Value::as_bool)
1146 .unwrap_or(false);
1147 println!("known: {known}");
1148 println!("close: {close}");
1149 println!("reload: {reload}");
1150 Ok(())
1151 }
1152}
1153
1154#[derive(Parser)]
1157pub struct UnregisterCommand {
1158 #[arg(long, value_name = "KEY")]
1160 pub key: String,
1161 #[arg(long, value_name = "PATH")]
1163 pub socket: Option<PathBuf>,
1164}
1165
1166impl UnregisterCommand {
1167 pub async fn execute(self) -> Result<()> {
1169 let socket = server::resolve_socket(self.socket)?;
1170 let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
1171 let removed = reply
1172 .get("removed")
1173 .and_then(Value::as_bool)
1174 .unwrap_or(false);
1175 println!("removed: {removed}");
1176 Ok(())
1177 }
1178}
1179
1180fn render_safety_report(path: &Path, report: &Value) -> String {
1185 let removable = report
1186 .get("removable")
1187 .and_then(Value::as_bool)
1188 .unwrap_or(false);
1189 let is_main = report
1190 .get("is_main")
1191 .and_then(Value::as_bool)
1192 .unwrap_or(false);
1193 let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
1194 let mut out = format!("Worktree: {}", path.display());
1195 out.push_str(&format!("\n removable: {removable}"));
1196 out.push_str(&format!("\n main working tree: {is_main}"));
1197 if open {
1198 let key = sanitize(
1199 report
1200 .get("window_key")
1201 .and_then(Value::as_str)
1202 .unwrap_or("-"),
1203 );
1204 let count = report
1205 .get("window_folder_count")
1206 .and_then(Value::as_u64)
1207 .unwrap_or(0);
1208 out.push_str(&format!(
1209 "\n open in a window: yes (key {key}, {count} folder(s))"
1210 ));
1211 } else {
1212 out.push_str("\n open in a window: no");
1213 }
1214 out.push_str(&render_notes("risks", report.get("risks")));
1215 out.push_str(&render_notes("info", report.get("info")));
1216 out
1217}
1218
1219fn render_notes(label: &str, notes: Option<&Value>) -> String {
1222 let notes = notes
1223 .and_then(Value::as_array)
1224 .map(Vec::as_slice)
1225 .unwrap_or_default();
1226 if notes.is_empty() {
1227 return String::new();
1228 }
1229 let mut out = format!("\n {label}:");
1230 for note in notes {
1231 let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
1232 let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
1233 out.push_str(&format!("\n - [{kind}] {detail}"));
1234 }
1235 out
1236}
1237
1238fn render_eligibility_report(report: &Value) -> String {
1243 let eligible = report
1244 .get("eligible")
1245 .and_then(Value::as_array)
1246 .map(Vec::as_slice)
1247 .unwrap_or_default();
1248 let skipped = report
1249 .get("skipped")
1250 .and_then(Value::as_array)
1251 .map(Vec::as_slice)
1252 .unwrap_or_default();
1253 let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
1254 for pr in eligible {
1255 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1256 let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
1257 let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
1258 out.push_str(&format!("\n eligible: PR #{number} [{branch}] {path}"));
1259 }
1260 for skip in skipped {
1261 let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
1262 let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
1263 let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
1264 out.push_str(&format!("\n skipped [{kind}]: {path} — {detail}"));
1265 }
1266 out
1267}
1268
1269fn render_enqueue_result(result: &Value) -> String {
1274 let queued = result
1275 .get("queued")
1276 .and_then(Value::as_array)
1277 .map(Vec::as_slice)
1278 .unwrap_or_default();
1279 let failed = result
1280 .get("failed")
1281 .and_then(Value::as_array)
1282 .map(Vec::as_slice)
1283 .unwrap_or_default();
1284 let skipped = result
1285 .get("skipped")
1286 .and_then(Value::as_array)
1287 .map_or(0, Vec::len);
1288 let mut out = format!(
1289 "Queued: {} / Failed: {} / Skipped: {}",
1290 queued.len(),
1291 failed.len(),
1292 skipped
1293 );
1294 for pr in queued {
1295 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1296 let already = pr
1297 .get("already_queued")
1298 .and_then(Value::as_bool)
1299 .unwrap_or(false);
1300 let suffix = if already { " (already queued)" } else { "" };
1301 out.push_str(&format!("\n queued: PR #{number}{suffix}"));
1302 }
1303 for pr in failed {
1304 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1305 let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
1306 out.push_str(&format!("\n failed: PR #{number} — {error}"));
1307 }
1308 out
1309}
1310
1311async fn confirm_removal(has_risks: bool) -> bool {
1318 confirm_removal_with(has_risks, read_stdin_line()).await
1319}
1320
1321async fn confirm_removal_with(
1326 has_risks: bool,
1327 read: impl std::future::Future<Output = Option<String>>,
1328) -> bool {
1329 use std::io::Write;
1330 eprint!("{}", confirm_prompt(has_risks));
1331 let _ = std::io::stderr().flush();
1332 read.await.as_deref().is_some_and(answer_is_yes)
1333}
1334
1335async fn confirm_enqueue(count: usize) -> bool {
1339 confirm_enqueue_with(count, read_stdin_line()).await
1340}
1341
1342async fn confirm_enqueue_with(
1346 count: usize,
1347 read: impl std::future::Future<Output = Option<String>>,
1348) -> bool {
1349 use std::io::Write;
1350 eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
1351 let _ = std::io::stderr().flush();
1352 read.await.as_deref().is_some_and(answer_is_yes)
1353}
1354
1355async fn read_stdin_line() -> Option<String> {
1359 tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
1360 .await
1361 .ok()
1362 .flatten()
1363}
1364
1365fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
1370 let mut answer = String::new();
1371 reader.read_line(&mut answer).ok().map(|_| answer)
1372}
1373
1374fn confirm_prompt(has_risks: bool) -> &'static str {
1377 if has_risks {
1378 "Delete this worktree despite the risks above? [y/N] "
1379 } else {
1380 "Delete this worktree? [y/N] "
1381 }
1382}
1383
1384fn answer_is_yes(answer: &str) -> bool {
1387 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
1388}
1389
1390async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
1397 let paths = worktree_paths(result);
1398 if paths.is_empty() {
1399 return;
1400 }
1401 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
1402 return;
1403 };
1404 if let Some(results) = reply.get("results").and_then(Value::as_object) {
1405 merge_ahead_behind(result, results);
1406 }
1407}
1408
1409fn worktree_paths(result: &Value) -> Vec<String> {
1412 let mut paths = Vec::new();
1413 for repo in result
1414 .get("repos")
1415 .and_then(Value::as_array)
1416 .map(Vec::as_slice)
1417 .unwrap_or_default()
1418 {
1419 for worktree in repo
1420 .get("worktrees")
1421 .and_then(Value::as_array)
1422 .map(Vec::as_slice)
1423 .unwrap_or_default()
1424 {
1425 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
1426 paths.push(path.to_string());
1427 }
1428 }
1429 }
1430 paths
1431}
1432
1433fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
1438 for repo in result
1439 .get_mut("repos")
1440 .and_then(Value::as_array_mut)
1441 .into_iter()
1442 .flatten()
1443 {
1444 for worktree in repo
1445 .get_mut("worktrees")
1446 .and_then(Value::as_array_mut)
1447 .into_iter()
1448 .flatten()
1449 {
1450 let Some(obj) = worktree.as_object_mut() else {
1454 continue;
1455 };
1456 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
1457 continue;
1458 };
1459 let Some(counts) = results.get(&path) else {
1460 continue;
1461 };
1462 if let (Some(ahead), Some(behind)) =
1465 (counts.get("ahead").cloned(), counts.get("behind").cloned())
1466 {
1467 obj.insert("ahead".to_string(), ahead);
1468 obj.insert("behind".to_string(), behind);
1469 }
1470 }
1471 }
1472}
1473
1474async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1477 let reply = DaemonClient::new(socket)
1478 .request(DaemonEnvelope::service(SERVICE, op, payload))
1479 .await?;
1480 reply_payload(reply)
1481}
1482
1483fn reply_payload(reply: DaemonReply) -> Result<Value> {
1486 if reply.ok {
1487 Ok(reply.payload)
1488 } else {
1489 bail!(
1490 "daemon returned an error: {}",
1491 reply.error.as_deref().unwrap_or("unknown error")
1492 )
1493 }
1494}
1495
1496fn render_windows(result: &Value) -> String {
1501 let windows = result
1502 .get("windows")
1503 .and_then(Value::as_array)
1504 .map(Vec::as_slice)
1505 .unwrap_or_default();
1506 if windows.is_empty() {
1507 return "No open windows.".to_string();
1508 }
1509 let mut out = format!(
1510 "{:<22} {:<24} {:<9} {:<40} {:>5}",
1511 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
1512 );
1513 for window in windows {
1514 let repo = sanitize(repo_name(window));
1515 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
1516 let sync = sync_summary(window);
1517 let folder_disp = folder_summary(window);
1518 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
1519 out.push_str(&format!(
1520 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
1521 ));
1522 }
1523 out
1524}
1525
1526fn render_tree(result: &Value) -> String {
1532 let repos = result
1533 .get("repos")
1534 .and_then(Value::as_array)
1535 .map(Vec::as_slice)
1536 .unwrap_or_default();
1537 if repos.is_empty() {
1538 return "No repositories open.".to_string();
1539 }
1540 let mut out = String::new();
1541 for (i, repo) in repos.iter().enumerate() {
1542 if i > 0 {
1545 out.push_str("\n\n");
1546 }
1547 out.push_str(&repo_header(repo));
1548 for worktree in repo
1549 .get("worktrees")
1550 .and_then(Value::as_array)
1551 .map(Vec::as_slice)
1552 .unwrap_or_default()
1553 {
1554 out.push('\n');
1555 out.push_str(&worktree_row(worktree));
1556 }
1557 }
1558 out
1559}
1560
1561fn repo_header(repo: &Value) -> String {
1564 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
1565 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
1566 match github_summary(repo) {
1567 Some(github) => format!("{name} ({github}) {root}"),
1568 None => format!("{name} {root}"),
1569 }
1570}
1571
1572fn github_summary(repo: &Value) -> Option<String> {
1575 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
1576 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
1577 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
1578}
1579
1580fn worktree_row(worktree: &Value) -> String {
1584 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
1585 '*'
1586 } else {
1587 ' '
1588 };
1589 let branch = sanitize(
1590 worktree
1591 .get("branch")
1592 .and_then(Value::as_str)
1593 .unwrap_or("-"),
1594 );
1595 let sync = sync_summary(worktree);
1596 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
1597 "open"
1598 } else {
1599 ""
1600 };
1601 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
1602 format!(" {marker} {branch:<24} {sync:<9} {open:<5} {path}")
1603}
1604
1605fn repo_name(window: &Value) -> &str {
1609 window
1610 .get("main_repo")
1611 .and_then(Value::as_str)
1612 .or_else(|| window.get("repo").and_then(Value::as_str))
1613 .unwrap_or("-")
1614}
1615
1616fn sync_summary(window: &Value) -> String {
1620 let ahead = window.get("ahead").and_then(Value::as_u64);
1621 let behind = window.get("behind").and_then(Value::as_u64);
1622 match (ahead, behind) {
1623 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
1624 _ => "-".to_string(),
1625 }
1626}
1627
1628fn folder_summary(window: &Value) -> String {
1631 let folders = window
1632 .get("folders")
1633 .and_then(Value::as_array)
1634 .map(Vec::as_slice)
1635 .unwrap_or_default();
1636 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
1637 let extra = folders.len().saturating_sub(1);
1638 if extra > 0 {
1639 format!("{first} (+{extra})")
1640 } else {
1641 first
1642 }
1643}
1644
1645fn sanitize(s: &str) -> String {
1649 s.chars().filter(|c| !c.is_control()).collect()
1650}
1651
1652fn age_secs(ts: Option<&str>) -> i64 {
1654 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1655 .map_or(0, |t| {
1656 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1657 })
1658}
1659
1660#[cfg(test)]
1661#[allow(clippy::unwrap_used, clippy::expect_used)]
1662mod tests {
1663 use super::*;
1664 use serde_json::json;
1665
1666 #[derive(Parser)]
1668 struct Wrapper {
1669 #[command(subcommand)]
1670 cmd: WorktreesSubcommands,
1671 }
1672
1673 fn parse(args: &[&str]) -> WorktreesSubcommands {
1674 let mut full = vec!["omni-dev"];
1675 full.extend_from_slice(args);
1676 Wrapper::try_parse_from(full).unwrap().cmd
1677 }
1678
1679 #[test]
1680 fn list_parses_flags_and_defaults() {
1681 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
1683 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
1685 assert_eq!(cmd.output, TableOrJson::Table);
1686 assert!(!cmd.json);
1687 assert!(cmd.socket.is_none());
1688
1689 let cmd =
1690 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1691 assert_eq!(cmd.output, TableOrJson::Json);
1692 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1693 }
1694
1695 #[test]
1696 fn list_deprecated_json_flag_still_parses() {
1697 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
1699 assert!(cmd.json);
1700 assert_eq!(cmd.output, TableOrJson::Table);
1701 }
1702
1703 #[test]
1704 fn tree_parses_flags_and_defaults() {
1705 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
1707 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1708 assert_eq!(cmd.output, TableOrJson::Table);
1709 assert!(cmd.socket.is_none());
1710
1711 let cmd =
1712 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1713 assert_eq!(cmd.output, TableOrJson::Json);
1714 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1715 }
1716
1717 #[test]
1718 fn focus_parses_path_and_socket() {
1719 assert!(matches!(
1721 parse(&["focus", "/home/me/wt"]),
1722 WorktreesSubcommands::Focus(_)
1723 ));
1724 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
1726 assert_eq!(cmd.path, Path::new("/home/me/wt"));
1727 assert!(cmd.socket.is_none());
1728
1729 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
1730 .unwrap();
1731 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1732
1733 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
1735 }
1736
1737 #[tokio::test]
1738 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
1739 let cmd = FocusCommand {
1742 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
1743 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
1744 };
1745 let err = cmd.execute().await.unwrap_err();
1746 assert!(
1747 err.to_string().contains("cannot resolve worktree path"),
1748 "{err}"
1749 );
1750 }
1751
1752 #[tokio::test]
1753 async fn focus_sends_the_open_op_for_an_existing_folder() {
1754 let (_dir, sock, server) =
1758 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
1759 let target = tempfile::tempdir().unwrap();
1760 let cmd = WorktreesCommand {
1761 command: WorktreesSubcommands::Focus(FocusCommand {
1762 path: target.path().to_path_buf(),
1763 socket: Some(sock),
1764 }),
1765 };
1766 cmd.execute(None).await.unwrap();
1767 server.await.unwrap();
1768 }
1769
1770 #[test]
1771 fn render_windows_handles_empty_replies() {
1772 assert_eq!(
1773 render_windows(&json!({ "windows": [] })),
1774 "No open windows."
1775 );
1776 assert_eq!(render_windows(&json!({})), "No open windows.");
1777 }
1778
1779 #[test]
1780 fn render_windows_renders_rows() {
1781 let result = json!({ "windows": [{
1782 "key": "w1",
1783 "repo": "omni-dev",
1784 "branch": "issue-1011",
1785 "ahead": 2,
1786 "behind": 1,
1787 "folders": ["/home/me/omni-dev", "/home/me/docs"],
1788 "last_seen": "2000-01-01T00:00:00Z",
1789 }]});
1790 let table = render_windows(&result);
1791 assert!(table.contains("omni-dev"), "{table}");
1792 assert!(table.contains("issue-1011"), "{table}");
1794 assert!(table.contains("+2 -1"), "{table}");
1795 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
1797 assert_eq!(table.lines().count(), 2, "{table}");
1799 }
1800
1801 #[test]
1802 fn render_windows_prefers_main_repo_over_companion_repo() {
1803 let result = json!({ "windows": [{
1807 "key": "w1",
1808 "repo": "issue-1250",
1809 "main_repo": "omni-dev",
1810 "branch": "issue-1250",
1811 "folders": ["/home/me/worktrees/issue-1250"],
1812 "last_seen": "2000-01-01T00:00:00Z",
1813 }]});
1814 let table = render_windows(&result);
1815 assert!(table.contains("omni-dev"), "{table}");
1816 let data_row = table.lines().nth(1).unwrap();
1819 assert!(data_row.starts_with("omni-dev"), "{data_row}");
1820 }
1821
1822 #[test]
1823 fn repo_name_falls_back_to_companion_repo_then_dash() {
1824 assert_eq!(
1825 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
1826 "omni-dev"
1827 );
1828 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
1829 assert_eq!(repo_name(&json!({})), "-");
1830 }
1831
1832 #[test]
1833 fn render_windows_strips_control_bytes() {
1834 let result = json!({ "windows": [{
1837 "key": "w1",
1838 "repo": "evil\x1b[31mrepo",
1839 "branch": "br\ranch\x07\u{9b}2J",
1840 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
1841 "last_seen": "2000-01-01T00:00:00Z",
1842 }]});
1843 let table = render_windows(&result);
1844 assert!(
1845 !table.contains(|c: char| c.is_control() && c != '\n'),
1846 "{table:?}"
1847 );
1848 assert!(table.contains("evil[31mrepo"), "{table:?}");
1850 assert!(table.contains("branch2J"), "{table:?}");
1851 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
1852 assert_eq!(table.lines().count(), 2, "{table:?}");
1854 }
1855
1856 #[test]
1857 fn sync_summary_formats_or_dashes() {
1858 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
1859 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
1860 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
1862 assert_eq!(sync_summary(&json!({})), "-");
1863 }
1864
1865 #[test]
1866 fn folder_summary_strips_control_bytes() {
1867 assert_eq!(
1868 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
1869 "/a[2J/b"
1870 );
1871 }
1872
1873 #[test]
1874 fn folder_summary_counts_extra_folders() {
1875 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
1876 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
1877 assert_eq!(
1878 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
1879 "/a (+2)"
1880 );
1881 }
1882
1883 #[test]
1884 fn age_secs_handles_absent_and_unparseable_and_past() {
1885 assert_eq!(age_secs(None), 0);
1886 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
1887 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
1888 }
1889
1890 #[test]
1891 fn render_tree_handles_empty_replies() {
1892 assert_eq!(
1893 render_tree(&json!({ "repos": [] })),
1894 "No repositories open."
1895 );
1896 assert_eq!(render_tree(&json!({})), "No repositories open.");
1897 }
1898
1899 #[test]
1900 fn worktree_paths_collects_every_worktree_in_render_order() {
1901 let result = json!({ "repos": [
1902 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
1904 { "worktrees": [ { "path": "/c" } ] },
1905 ]});
1906 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
1907 assert!(worktree_paths(&json!({})).is_empty());
1909 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
1910 }
1911
1912 #[test]
1913 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
1914 let mut result = json!({ "repos": [{ "worktrees": [
1918 { "path": "/a", "branch": "main" },
1919 { "path": "/b", "branch": "feature" },
1920 ]}]});
1921 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
1922 merge_ahead_behind(&mut result, results.as_object().unwrap());
1923
1924 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1925 let a = &worktrees[0];
1926 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
1927 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
1928 assert_eq!(sync_summary(a), "+2 -1");
1930 let b = &worktrees[1];
1931 assert!(b.get("ahead").is_none(), "{b:?}");
1932 assert!(b.get("behind").is_none(), "{b:?}");
1933 assert_eq!(sync_summary(b), "-");
1934 }
1935
1936 #[test]
1937 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
1938 let mut result = json!({ "repos": [{ "worktrees": [
1942 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
1946 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
1948
1949 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1950 assert_eq!(worktrees[0], json!("not-an-object"));
1952 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
1954 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
1956 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
1957 }
1958
1959 #[tokio::test]
1960 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
1961 let mut result = json!({ "repos": [] });
1964 let before = result.clone();
1965 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1966 assert_eq!(result, before);
1967 }
1968
1969 #[tokio::test]
1970 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
1971 let mut result =
1974 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1975 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1976 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1977 assert!(wt.get("ahead").is_none(), "{wt:?}");
1978 assert!(wt.get("behind").is_none(), "{wt:?}");
1979 }
1980
1981 fn fake_daemon_reply(
1986 reply: Value,
1987 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
1988 use futures::{SinkExt, StreamExt};
1989 use tokio::net::UnixListener;
1990 use tokio_util::codec::{Framed, LinesCodec};
1991
1992 let dir = tempfile::tempdir_in("/tmp").unwrap();
1994 let sock = dir.path().join("d.sock");
1995 let listener = UnixListener::bind(&sock).unwrap();
1996 let server = tokio::spawn(async move {
1997 let (stream, _) = listener.accept().await.unwrap();
1998 let mut framed = Framed::new(stream, LinesCodec::new());
1999 let _req = framed.next().await.unwrap().unwrap();
2000 framed
2001 .send(serde_json::to_string(&reply).unwrap())
2002 .await
2003 .unwrap();
2004 });
2005 (dir, sock, server)
2006 }
2007
2008 fn fake_daemon_replies(
2012 replies: Vec<Value>,
2013 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2014 use futures::{SinkExt, StreamExt};
2015 use tokio::net::UnixListener;
2016 use tokio_util::codec::{Framed, LinesCodec};
2017
2018 let dir = tempfile::tempdir_in("/tmp").unwrap();
2019 let sock = dir.path().join("d.sock");
2020 let listener = UnixListener::bind(&sock).unwrap();
2021 let server = tokio::spawn(async move {
2022 for reply in replies {
2023 let (stream, _) = listener.accept().await.unwrap();
2024 let mut framed = Framed::new(stream, LinesCodec::new());
2025 let _req = framed.next().await.unwrap().unwrap();
2026 framed
2027 .send(serde_json::to_string(&reply).unwrap())
2028 .await
2029 .unwrap();
2030 }
2031 });
2032 (dir, sock, server)
2033 }
2034
2035 #[tokio::test]
2036 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
2037 let (_dir, sock, server) = fake_daemon_reply(
2038 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
2039 );
2040 let mut result =
2041 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2042 enrich_ahead_behind(&sock, &mut result).await;
2043 server.await.unwrap();
2044
2045 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2046 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
2047 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
2048 }
2049
2050 #[tokio::test]
2051 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
2052 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
2055 let mut result =
2056 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2057 enrich_ahead_behind(&sock, &mut result).await;
2058 server.await.unwrap();
2059
2060 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2061 assert!(wt.get("ahead").is_none(), "{wt:?}");
2062 assert!(wt.get("behind").is_none(), "{wt:?}");
2063 }
2064
2065 #[test]
2066 fn render_tree_groups_repos_and_worktrees() {
2067 let result = json!({ "repos": [{
2068 "main_repo": "omni-dev",
2069 "github": { "owner": "rust-works", "name": "omni-dev" },
2070 "root": "/home/me/omni-dev",
2071 "worktrees": [
2072 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
2073 "is_main": true, "open": true, "window_key": "w1" },
2074 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
2075 "is_main": false, "open": false },
2076 ],
2077 }]});
2078 let out = render_tree(&result);
2079 let header = out.lines().next().unwrap();
2081 assert!(header.contains("omni-dev"), "{out}");
2082 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
2083 assert!(header.contains("/home/me/omni-dev"), "{out}");
2084 assert!(
2086 out.lines()
2087 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
2088 "{out}"
2089 );
2090 let linked = out
2092 .lines()
2093 .find(|l| l.contains("issue-1300"))
2094 .unwrap_or_default();
2095 assert!(!linked.contains('*'), "{linked}");
2096 assert!(!linked.contains("open"), "{linked}");
2097 assert!(linked.contains("+1 -3"), "{linked}");
2098 assert_eq!(out.lines().count(), 3, "{out}");
2100 }
2101
2102 #[test]
2103 fn render_tree_separates_multiple_repos_with_blank_line() {
2104 let result = json!({ "repos": [
2105 {
2106 "main_repo": "alpha",
2107 "root": "/r/alpha",
2108 "worktrees": [
2109 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
2110 ],
2111 },
2112 {
2113 "main_repo": "beta",
2114 "root": "/r/beta",
2115 "worktrees": [
2116 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
2117 ],
2118 },
2119 ]});
2120 let out = render_tree(&result);
2121 assert!(
2123 out.contains("\n\nbeta"),
2124 "repos not blank-separated: {out:?}"
2125 );
2126 let alpha = out.find("alpha").unwrap();
2127 let beta = out.find("beta").unwrap();
2128 assert!(alpha < beta, "repo order not preserved: {out}");
2129 assert_eq!(out.lines().count(), 5, "{out:?}");
2130 }
2131
2132 #[test]
2133 fn render_tree_omits_github_for_non_github_repo() {
2134 let result = json!({ "repos": [{
2135 "main_repo": "internal",
2136 "root": "/srv/internal",
2137 "worktrees": [
2138 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
2139 ],
2140 }]});
2141 let out = render_tree(&result);
2142 assert!(!out.contains("github:"), "{out}");
2143 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
2144 }
2145
2146 #[test]
2147 fn render_tree_strips_control_bytes() {
2148 let result = json!({ "repos": [{
2151 "main_repo": "evil\x1b[31mrepo",
2152 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
2153 "root": "/tmp/r\x1b]0;x\x07oot",
2154 "worktrees": [
2155 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
2156 ],
2157 }]});
2158 let out = render_tree(&result);
2159 assert!(
2160 !out.contains(|c: char| c.is_control() && c != '\n'),
2161 "{out:?}"
2162 );
2163 assert_eq!(out.lines().count(), 2, "{out:?}");
2165 }
2166
2167 #[test]
2168 fn github_summary_needs_both_owner_and_name() {
2169 assert_eq!(
2170 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
2171 Some("github: o/n")
2172 );
2173 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
2174 assert_eq!(github_summary(&json!({})), None);
2175 }
2176
2177 #[test]
2178 fn reply_payload_unwraps_ok_and_maps_errors() {
2179 assert_eq!(
2181 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2182 json!({ "a": 1 })
2183 );
2184 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2186 assert!(err.to_string().contains("boom"), "{err}");
2187 let err = reply_payload(DaemonReply {
2189 ok: false,
2190 payload: Value::Null,
2191 error: None,
2192 })
2193 .unwrap_err();
2194 assert!(err.to_string().contains("unknown error"), "{err}");
2195 }
2196
2197 #[test]
2200 fn new_subcommands_route_and_require_their_args() {
2201 assert!(matches!(
2202 parse(&["close", "/home/me/wt"]),
2203 WorktreesSubcommands::Close(_)
2204 ));
2205 assert!(matches!(
2206 parse(&["show-closed"]),
2207 WorktreesSubcommands::ShowClosed(_)
2208 ));
2209 assert!(matches!(
2210 parse(&["register", "--key", "w1"]),
2211 WorktreesSubcommands::Register(_)
2212 ));
2213 assert!(matches!(
2214 parse(&["heartbeat", "--key", "w1"]),
2215 WorktreesSubcommands::Heartbeat(_)
2216 ));
2217 assert!(matches!(
2218 parse(&["unregister", "--key", "w1"]),
2219 WorktreesSubcommands::Unregister(_)
2220 ));
2221
2222 assert!(CloseCommand::try_parse_from(["close"]).is_err());
2224 assert!(RegisterCommand::try_parse_from(["register"]).is_err());
2225 assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
2226 assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
2227 }
2228
2229 #[test]
2230 fn close_parses_flags() {
2231 let cmd = CloseCommand::try_parse_from([
2232 "close",
2233 "/home/me/wt",
2234 "--window-only",
2235 "--dry-run",
2236 "-y",
2237 "--socket",
2238 "/tmp/d.sock",
2239 ])
2240 .unwrap();
2241 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2242 assert!(cmd.window_only && cmd.dry_run && cmd.yes);
2243 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2244
2245 let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
2247 assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
2248 }
2249
2250 #[test]
2251 fn tree_follow_flag_parses() {
2252 let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
2253 assert!(cmd.follow);
2254 let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
2255 assert!(cmd.follow);
2256 assert_eq!(cmd.output, TableOrJson::Json);
2257 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2258 assert!(!cmd.follow);
2259 }
2260
2261 #[test]
2262 fn show_closed_parses_optional_bool() {
2263 assert!(ShowClosedCommand::try_parse_from(["show-closed"])
2264 .unwrap()
2265 .value
2266 .is_none());
2267 assert_eq!(
2268 ShowClosedCommand::try_parse_from(["show-closed", "false"])
2269 .unwrap()
2270 .value,
2271 Some(false)
2272 );
2273 assert_eq!(
2274 ShowClosedCommand::try_parse_from(["show-closed", "true"])
2275 .unwrap()
2276 .value,
2277 Some(true)
2278 );
2279 assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
2281 }
2282
2283 #[test]
2284 fn register_collects_repeated_folders() {
2285 let cmd = RegisterCommand::try_parse_from([
2286 "register",
2287 "--key",
2288 "w1",
2289 "--folder",
2290 "/a",
2291 "--folder",
2292 "/b",
2293 "--repo-name",
2294 "r",
2295 "--pid",
2296 "42",
2297 ])
2298 .unwrap();
2299 assert_eq!(cmd.key, "w1");
2300 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
2301 assert_eq!(cmd.repo_name.as_deref(), Some("r"));
2302 assert_eq!(cmd.pid, Some(42));
2303 }
2304
2305 #[test]
2306 fn answer_is_yes_accepts_only_affirmatives() {
2307 for yes in ["y", "Y", "yes", "YES", " yes \n"] {
2308 assert!(answer_is_yes(yes), "{yes:?}");
2309 }
2310 for no in ["", "n", "no", "nope", "true", "\n"] {
2311 assert!(!answer_is_yes(no), "{no:?}");
2312 }
2313 }
2314
2315 #[test]
2316 fn confirm_prompt_mentions_risks_only_when_present() {
2317 assert!(confirm_prompt(true).contains("risks"));
2321 assert!(!confirm_prompt(false).contains("risks"));
2322 assert!(confirm_prompt(true).contains("[y/N]"));
2323 assert!(confirm_prompt(false).contains("[y/N]"));
2324 }
2325
2326 #[test]
2327 fn read_line_from_maps_input_and_eof() {
2328 use std::io::Cursor;
2329 assert_eq!(
2333 read_line_from(&mut Cursor::new("y\n")).as_deref(),
2334 Some("y\n")
2335 );
2336 assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
2337 assert_eq!(
2338 read_line_from(&mut Cursor::new("no-newline")).as_deref(),
2339 Some("no-newline")
2340 );
2341 }
2342
2343 #[test]
2344 fn render_safety_report_renders_fields_and_notes() {
2345 let report = json!({
2346 "removable": true,
2347 "is_main": false,
2348 "open": true,
2349 "window_key": "w1",
2350 "window_folder_count": 2,
2351 "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
2352 "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
2353 });
2354 let out = render_safety_report(Path::new("/home/me/wt"), &report);
2355 assert!(out.contains("/home/me/wt"), "{out}");
2356 assert!(out.contains("removable: true"), "{out}");
2357 assert!(
2358 out.contains("open in a window: yes (key w1, 2 folder(s))"),
2359 "{out}"
2360 );
2361 assert!(out.contains("[dirty] uncommitted changes"), "{out}");
2362 assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
2363 }
2364
2365 #[test]
2366 fn render_safety_report_handles_no_window_and_no_notes() {
2367 let report = json!({ "removable": false, "is_main": true, "open": false });
2368 let out = render_safety_report(Path::new("/r"), &report);
2369 assert!(out.contains("removable: false"), "{out}");
2370 assert!(out.contains("main working tree: true"), "{out}");
2371 assert!(out.contains("open in a window: no"), "{out}");
2372 assert!(!out.contains("risks:"), "{out}");
2374 assert!(!out.contains("info:"), "{out}");
2375 }
2376
2377 #[test]
2378 fn render_safety_report_strips_control_bytes() {
2379 let report = json!({
2382 "removable": true, "is_main": false, "open": true,
2383 "window_key": "w\x1b[31m1", "window_folder_count": 1,
2384 "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
2385 "info": [],
2386 });
2387 let out = render_safety_report(Path::new("/r"), &report);
2388 assert!(
2389 !out.contains(|c: char| c.is_control() && c != '\n'),
2390 "{out:?}"
2391 );
2392 }
2393
2394 fn fake_daemon_seq(
2400 replies: Vec<Value>,
2401 ) -> (
2402 tempfile::TempDir,
2403 PathBuf,
2404 tokio::task::JoinHandle<Vec<Value>>,
2405 ) {
2406 use futures::{SinkExt, StreamExt};
2407 use tokio::net::UnixListener;
2408 use tokio_util::codec::{Framed, LinesCodec};
2409
2410 let dir = tempfile::tempdir_in("/tmp").unwrap();
2411 let sock = dir.path().join("d.sock");
2412 let listener = UnixListener::bind(&sock).unwrap();
2413 let server = tokio::spawn(async move {
2414 let mut requests = Vec::new();
2415 for reply in replies {
2416 let (stream, _) = listener.accept().await.unwrap();
2417 let mut framed = Framed::new(stream, LinesCodec::new());
2418 let req = framed.next().await.unwrap().unwrap();
2419 requests.push(serde_json::from_str::<Value>(&req).unwrap());
2420 framed
2421 .send(serde_json::to_string(&reply).unwrap())
2422 .await
2423 .unwrap();
2424 }
2425 requests
2426 });
2427 (dir, sock, server)
2428 }
2429
2430 #[tokio::test]
2431 async fn close_window_only_sends_remove_false() {
2432 let (_dir, sock, server) =
2433 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
2434 let target = tempfile::tempdir().unwrap();
2435 CloseCommand {
2436 path: target.path().to_path_buf(),
2437 window_only: true,
2438 dry_run: false,
2439 yes: false,
2440 socket: Some(sock),
2441 }
2442 .execute()
2443 .await
2444 .unwrap();
2445 let reqs = server.await.unwrap();
2446 assert_eq!(reqs.len(), 1);
2449 assert_eq!(reqs[0]["op"], "close");
2450 assert_eq!(reqs[0]["payload"]["remove"], json!(false));
2451 assert!(
2452 reqs[0]["payload"].get("confirmed").is_none(),
2453 "{:?}",
2454 reqs[0]
2455 );
2456 let want = std::fs::canonicalize(target.path()).unwrap();
2458 assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
2459 }
2460
2461 #[tokio::test]
2462 async fn close_window_only_dry_run_never_contacts_the_daemon() {
2463 let target = tempfile::tempdir().unwrap();
2466 CloseCommand {
2467 path: target.path().to_path_buf(),
2468 window_only: true,
2469 dry_run: true,
2470 yes: false,
2471 socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
2472 }
2473 .execute()
2474 .await
2475 .unwrap();
2476 }
2477
2478 #[tokio::test]
2479 async fn close_dry_run_only_runs_phase_one() {
2480 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2482 "ok": true,
2483 "payload": { "removable": true, "is_main": false, "open": false,
2484 "window_folder_count": 0, "risks": [], "info": [] }
2485 })]);
2486 let target = tempfile::tempdir().unwrap();
2487 CloseCommand {
2488 path: target.path().to_path_buf(),
2489 window_only: false,
2490 dry_run: true,
2491 yes: false,
2492 socket: Some(sock),
2493 }
2494 .execute()
2495 .await
2496 .unwrap();
2497 let reqs = server.await.unwrap();
2498 assert_eq!(reqs.len(), 1);
2500 assert_eq!(reqs[0]["op"], "close");
2501 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2502 assert!(
2503 reqs[0]["payload"].get("confirmed").is_none(),
2504 "{:?}",
2505 reqs[0]
2506 );
2507 }
2508
2509 #[tokio::test]
2510 async fn close_yes_executes_phase_two() {
2511 let (_dir, sock, server) = fake_daemon_seq(vec![
2513 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2514 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2515 json!({ "ok": true, "payload": { "removed": true } }),
2516 ]);
2517 let target = tempfile::tempdir().unwrap();
2518 CloseCommand {
2519 path: target.path().to_path_buf(),
2520 window_only: false,
2521 dry_run: false,
2522 yes: true,
2523 socket: Some(sock),
2524 }
2525 .execute()
2526 .await
2527 .unwrap();
2528 let reqs = server.await.unwrap();
2529 assert_eq!(reqs.len(), 2);
2531 assert_eq!(reqs[0]["op"], "close");
2532 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2533 assert!(
2534 reqs[0]["payload"].get("confirmed").is_none(),
2535 "{:?}",
2536 reqs[0]
2537 );
2538 assert_eq!(reqs[1]["op"], "close");
2539 assert_eq!(reqs[1]["payload"]["remove"], json!(true));
2540 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2541 assert!(
2543 reqs[1]["payload"].get("requester_key").is_none(),
2544 "{:?}",
2545 reqs[1]
2546 );
2547 }
2548
2549 #[tokio::test]
2550 async fn close_refuses_a_non_removable_target() {
2551 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2554 "ok": true,
2555 "payload": { "removable": false, "is_main": true, "open": false,
2556 "window_folder_count": 0, "risks": [], "info": [] }
2557 })]);
2558 let target = tempfile::tempdir().unwrap();
2559 let err = CloseCommand {
2560 path: target.path().to_path_buf(),
2561 window_only: false,
2562 dry_run: false,
2563 yes: true,
2564 socket: Some(sock),
2565 }
2566 .execute()
2567 .await
2568 .unwrap_err();
2569 assert!(
2570 err.to_string().contains("not a removable worktree"),
2571 "{err}"
2572 );
2573 assert_eq!(server.await.unwrap().len(), 1);
2575 }
2576
2577 #[tokio::test]
2578 async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
2579 let err = CloseCommand {
2580 path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
2581 window_only: false,
2582 dry_run: false,
2583 yes: true,
2584 socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
2585 }
2586 .execute()
2587 .await
2588 .unwrap_err();
2589 assert!(
2590 err.to_string().contains("cannot resolve worktree path"),
2591 "{err}"
2592 );
2593 }
2594
2595 #[tokio::test]
2596 async fn show_closed_sets_and_reads() {
2597 let (_dir, sock, server) =
2599 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2600 ShowClosedCommand {
2601 value: Some(false),
2602 socket: Some(sock),
2603 }
2604 .execute()
2605 .await
2606 .unwrap();
2607 let reqs = server.await.unwrap();
2608 assert_eq!(reqs[0]["op"], "set-show-closed");
2609 assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
2610
2611 let (_dir, sock, server) = fake_daemon_seq(vec![
2613 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2614 ]);
2615 ShowClosedCommand {
2616 value: None,
2617 socket: Some(sock),
2618 }
2619 .execute()
2620 .await
2621 .unwrap();
2622 assert_eq!(server.await.unwrap()[0]["op"], "tree");
2624 }
2625
2626 #[tokio::test]
2627 async fn register_heartbeat_unregister_send_their_ops() {
2628 let (_dir, sock, server) =
2629 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2630 RegisterCommand {
2631 key: "w1".to_string(),
2632 folders: vec![PathBuf::from("/a")],
2633 repo_name: Some("r".to_string()),
2634 title: None,
2635 pid: Some(7),
2636 socket: Some(sock),
2637 }
2638 .execute()
2639 .await
2640 .unwrap();
2641 let reqs = server.await.unwrap();
2642 assert_eq!(reqs[0]["op"], "register");
2644 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2645 assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
2646 assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
2647 assert_eq!(reqs[0]["payload"]["pid"], json!(7));
2648
2649 let (_dir, sock, server) = fake_daemon_seq(vec![
2650 json!({ "ok": true, "payload": { "known": true, "close": true } }),
2651 ]);
2652 HeartbeatCommand {
2653 key: "w1".to_string(),
2654 socket: Some(sock),
2655 }
2656 .execute()
2657 .await
2658 .unwrap();
2659 let reqs = server.await.unwrap();
2660 assert_eq!(reqs[0]["op"], "heartbeat");
2661 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2662
2663 let (_dir, sock, server) =
2664 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
2665 UnregisterCommand {
2666 key: "w1".to_string(),
2667 socket: Some(sock),
2668 }
2669 .execute()
2670 .await
2671 .unwrap();
2672 let reqs = server.await.unwrap();
2673 assert_eq!(reqs[0]["op"], "unregister");
2674 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2675 }
2676
2677 #[tokio::test]
2678 async fn tree_follow_renders_each_pushed_frame() {
2679 use crate::daemon::testutil::fake_daemon_stream;
2680
2681 let (_dir, sock, server) = fake_daemon_stream(vec![
2683 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
2684 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2685 ]);
2686 follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
2687 server.await.unwrap();
2688
2689 let (_dir, sock, server) = fake_daemon_stream(vec![
2692 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
2693 ]);
2694 follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
2695 server.await.unwrap();
2696
2697 let (_dir, sock, server) = fake_daemon_stream(vec![
2700 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
2701 ]);
2702 TreeCommand {
2703 socket: Some(sock),
2704 output: TableOrJson::Json,
2705 follow: true,
2706 }
2707 .execute()
2708 .await
2709 .unwrap();
2710 server.await.unwrap();
2711 }
2712
2713 #[tokio::test]
2714 async fn worktrees_command_routes_each_new_subcommand() {
2715 let target = tempfile::tempdir().unwrap();
2719 WorktreesCommand {
2721 command: WorktreesSubcommands::Close(CloseCommand {
2722 path: target.path().to_path_buf(),
2723 window_only: true,
2724 dry_run: true,
2725 yes: false,
2726 socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
2727 }),
2728 }
2729 .execute(None)
2730 .await
2731 .unwrap();
2732
2733 WorktreesCommand {
2736 command: WorktreesSubcommands::Rebase(RebaseCommand {
2737 paths: vec![target.path().to_path_buf()],
2738 dry_run: true,
2739 ..rebase_cmd()
2740 }),
2741 }
2742 .execute(None)
2743 .await
2744 .unwrap();
2745
2746 let (_d, sock, server) =
2748 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2749 WorktreesCommand {
2750 command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
2751 value: Some(true),
2752 socket: Some(sock),
2753 }),
2754 }
2755 .execute(None)
2756 .await
2757 .unwrap();
2758 server.await.unwrap();
2759
2760 let (_d, sock, server) = fake_daemon_seq(vec![json!({
2762 "ok": true,
2763 "payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
2764 })]);
2765 WorktreesCommand {
2766 command: WorktreesSubcommands::Reposition(RepositionCommand {
2767 paths: Vec::new(),
2768 reference: None,
2769 dry_run: false,
2770 undo: true,
2771 output: TableOrJson::Table,
2772 socket: Some(sock),
2773 }),
2774 }
2775 .execute(None)
2776 .await
2777 .unwrap();
2778 server.await.unwrap();
2779
2780 let (_d, sock, server) = fake_daemon_seq(vec![
2783 json!({ "ok": true, "payload": { "windows": [] } }),
2784 json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
2785 ]);
2786 WorktreesCommand {
2787 command: WorktreesSubcommands::Reload(ReloadCommand {
2788 paths: Vec::new(),
2789 output: TableOrJson::Table,
2790 socket: Some(sock),
2791 }),
2792 }
2793 .execute(None)
2794 .await
2795 .unwrap();
2796 server.await.unwrap();
2797
2798 let (_d, sock, server) =
2800 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2801 WorktreesCommand {
2802 command: WorktreesSubcommands::Register(RegisterCommand {
2803 key: "w1".to_string(),
2804 folders: vec![],
2805 repo_name: None,
2806 title: None,
2807 pid: None,
2808 socket: Some(sock),
2809 }),
2810 }
2811 .execute(None)
2812 .await
2813 .unwrap();
2814 server.await.unwrap();
2815
2816 let (_d, sock, server) =
2818 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
2819 WorktreesCommand {
2820 command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
2821 key: "w1".to_string(),
2822 socket: Some(sock),
2823 }),
2824 }
2825 .execute(None)
2826 .await
2827 .unwrap();
2828 server.await.unwrap();
2829
2830 let (_d, sock, server) =
2832 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
2833 WorktreesCommand {
2834 command: WorktreesSubcommands::Unregister(UnregisterCommand {
2835 key: "w1".to_string(),
2836 socket: Some(sock),
2837 }),
2838 }
2839 .execute(None)
2840 .await
2841 .unwrap();
2842 server.await.unwrap();
2843 }
2844
2845 #[tokio::test]
2846 async fn close_aborts_when_confirmation_is_declined() {
2847 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2851 "ok": true,
2852 "payload": { "removable": true, "is_main": false, "open": false,
2853 "window_folder_count": 0, "risks": [], "info": [] }
2854 })]);
2855 let target = tempfile::tempdir().unwrap();
2856 CloseCommand {
2857 path: target.path().to_path_buf(),
2858 window_only: false,
2859 dry_run: false,
2860 yes: false,
2861 socket: Some(sock),
2862 }
2863 .execute_with(|_has_risks| async { false })
2864 .await
2865 .unwrap();
2866 assert_eq!(server.await.unwrap().len(), 1);
2867 }
2868
2869 #[tokio::test]
2870 async fn close_deletes_when_confirmation_is_accepted() {
2871 let (_dir, sock, server) = fake_daemon_seq(vec![
2874 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2875 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2876 json!({ "ok": true, "payload": { "removed": true } }),
2877 ]);
2878 let target = tempfile::tempdir().unwrap();
2879 CloseCommand {
2880 path: target.path().to_path_buf(),
2881 window_only: false,
2882 dry_run: false,
2883 yes: false,
2884 socket: Some(sock),
2885 }
2886 .execute_with(|_has_risks| async { true })
2887 .await
2888 .unwrap();
2889 let reqs = server.await.unwrap();
2890 assert_eq!(reqs.len(), 2);
2891 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2892 }
2893
2894 #[tokio::test]
2895 async fn confirm_removal_with_decides_from_the_answer() {
2896 assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
2899 assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
2900 assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
2901 assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
2902 assert!(!confirm_removal_with(false, async { None }).await);
2903 }
2904
2905 fn rebase_cmd() -> RebaseCommand {
2909 RebaseCommand {
2910 paths: Vec::new(),
2911 all: false,
2912 onto: None,
2913 autostash: false,
2914 dry_run: false,
2915 keep_conflicts: false,
2916 yes: false,
2917 output: TableOrJson::Table,
2918 }
2919 }
2920
2921 #[test]
2922 fn rebase_parses_paths_and_flags() {
2923 let cmd = RebaseCommand::try_parse_from([
2924 "rebase",
2925 "/wt/a",
2926 "/wt/b",
2927 "--onto",
2928 "origin/release",
2929 "--autostash",
2930 "--dry-run",
2931 "--keep-conflicts",
2932 "-y",
2933 "-o",
2934 "json",
2935 ])
2936 .unwrap();
2937 assert_eq!(
2938 cmd.paths,
2939 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
2940 );
2941 assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
2942 assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
2943 assert!(matches!(cmd.output, TableOrJson::Json));
2944 }
2945
2946 #[test]
2947 fn rebase_defaults_are_conservative() {
2948 let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
2949 assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
2950 assert!(!cmd.keep_conflicts);
2953 assert_eq!(cmd.onto, None);
2954 assert!(matches!(cmd.output, TableOrJson::Table));
2955 }
2956
2957 #[test]
2958 fn rebase_requires_a_target() {
2959 let err = rebase_cmd().selection(None).unwrap_err().to_string();
2962 assert!(err.contains("--all"), "expected a usage hint, got: {err}");
2963 }
2964
2965 #[test]
2966 fn rebase_rejects_paths_together_with_all() {
2967 let cmd = RebaseCommand {
2968 paths: vec![PathBuf::from("/wt/a")],
2969 all: true,
2970 ..rebase_cmd()
2971 };
2972 let err = cmd.selection(None).unwrap_err().to_string();
2973 assert!(err.contains("not both"), "got: {err}");
2974 }
2975
2976 #[test]
2977 fn rebase_selection_maps_paths_and_all() {
2978 let cmd = RebaseCommand {
2979 paths: vec![PathBuf::from("/wt/a")],
2980 ..rebase_cmd()
2981 };
2982 assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
2983 let all = RebaseCommand {
2984 all: true,
2985 ..rebase_cmd()
2986 };
2987 assert!(matches!(
2988 all.selection(None).unwrap(),
2989 Selection::All { .. }
2990 ));
2991 }
2992
2993 #[test]
2994 fn rebase_prompt_agrees_in_number() {
2995 assert!(rebase_prompt(1).contains("1 worktree ("));
2996 assert!(rebase_prompt(3).contains("3 worktrees ("));
2997 assert!(rebase_prompt(2).contains("rewrites branch history"));
2999 }
3000
3001 #[tokio::test]
3002 async fn confirm_rebase_with_decides_from_the_answer() {
3003 assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
3004 assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
3005 assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
3006 assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
3007 assert!(!confirm_rebase_with(1, async { None }).await);
3008 }
3009
3010 #[test]
3011 fn fetch_line_reports_each_repos_single_fetch() {
3012 let ok = FetchOutcome {
3013 repo_root: PathBuf::from("/repo"),
3014 onto: "origin/main".to_string(),
3015 fetched: true,
3016 ok: true,
3017 detail: None,
3018 };
3019 assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
3020
3021 let failed = FetchOutcome {
3022 detail: Some("host unreachable".to_string()),
3023 ok: false,
3024 ..ok.clone()
3025 };
3026 assert!(fetch_line(&failed).contains("FAILED"));
3027
3028 let local = FetchOutcome {
3029 fetched: false,
3030 onto: "develop".to_string(),
3031 ..ok
3032 };
3033 assert!(fetch_line(&local).contains("nothing fetched"));
3034 }
3035
3036 #[test]
3037 fn outcome_rows_render_each_status() {
3038 let row = |result| {
3039 outcome_row(&WorktreeOutcome {
3040 path: PathBuf::from("/wt"),
3041 branch: Some("feature".to_string()),
3042 onto: "origin/main".to_string(),
3043 result,
3044 })
3045 };
3046 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
3047 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
3048 assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
3049 assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
3050 assert!(row(RebaseResult::Skipped {
3051 reason: SkipReason::Dirty
3052 })
3053 .contains("--autostash"));
3054 assert!(row(RebaseResult::Skipped {
3055 reason: SkipReason::MainWorkingTree
3056 })
3057 .contains("main working tree"));
3058 assert!(row(RebaseResult::Conflict {
3059 detail: "CONFLICT (content)".to_string(),
3060 left_in_place: false,
3061 })
3062 .contains("conflict"));
3063 let kept = row(RebaseResult::Conflict {
3066 detail: "CONFLICT (content)".to_string(),
3067 left_in_place: true,
3068 });
3069 assert!(kept.contains("conflict"), "{kept}");
3070 assert!(kept.contains("git rebase --continue"), "{kept}");
3071 assert!(row(RebaseResult::FetchFailed {
3072 detail: "host unreachable".to_string()
3073 })
3074 .contains("fetch-failed"));
3075 assert!(row(RebaseResult::Skipped {
3077 reason: SkipReason::DetachedHead
3078 })
3079 .contains("detached HEAD"));
3080 assert!(row(RebaseResult::Skipped {
3081 reason: SkipReason::OperationInProgress
3082 })
3083 .contains("in progress"));
3084 assert!(row(RebaseResult::Skipped {
3085 reason: SkipReason::NotAWorktree
3086 })
3087 .contains("not a git worktree"));
3088 assert!(row(RebaseResult::Skipped {
3089 reason: SkipReason::NoOntoRef
3090 })
3091 .contains("resolve the target ref"));
3092 }
3093
3094 #[test]
3095 fn print_emits_both_json_and_table_without_error() {
3096 let fetches = vec![FetchOutcome {
3097 repo_root: PathBuf::from("/r"),
3098 onto: "origin/main".to_string(),
3099 fetched: true,
3100 ok: true,
3101 detail: None,
3102 }];
3103 let outcomes = vec![WorktreeOutcome {
3104 path: PathBuf::from("/wt"),
3105 branch: Some("feature".to_string()),
3106 onto: "origin/main".to_string(),
3107 result: RebaseResult::UpToDate,
3108 }];
3109 let json_cmd = RebaseCommand {
3111 dry_run: true,
3112 output: TableOrJson::Json,
3113 ..rebase_cmd()
3114 };
3115 json_cmd.print(true, &fetches, &outcomes).unwrap();
3116 rebase_cmd().print(false, &fetches, &outcomes).unwrap();
3117 }
3118
3119 #[test]
3120 fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
3121 assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
3122 let long = "x".repeat(200);
3123 let out = brief(&long);
3124 assert_eq!(out.chars().count(), 100);
3125 assert!(out.ends_with("..."));
3126 assert_eq!(brief("a\u{7}b"), "ab");
3128 }
3129
3130 #[test]
3131 fn empty_report_renders_placeholders() {
3132 assert_eq!(render_fetches(&[]), "No repository selected.");
3133 assert_eq!(render_outcomes(&[]), "No worktrees selected.");
3134 }
3135
3136 #[allow(clippy::await_holding_lock)]
3142 #[tokio::test]
3143 async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
3144 let _guard = worktree_rebase::test_serial_lock();
3149 let Some(scenario) = BehindScenario::build() else {
3150 return; };
3152 let before = scenario.worktree_head();
3153 RebaseCommand {
3154 paths: vec![scenario.worktree.clone()],
3155 ..rebase_cmd()
3156 }
3157 .execute_with(None, |pending| async move {
3158 assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
3159 false
3160 })
3161 .await
3162 .unwrap();
3163 assert_eq!(
3164 scenario.worktree_head(),
3165 before,
3166 "declining the confirm must not rebase"
3167 );
3168 }
3169
3170 #[allow(clippy::await_holding_lock)]
3173 #[tokio::test]
3174 async fn rebase_confirmed_rebases_the_behind_worktree() {
3175 let _guard = worktree_rebase::test_serial_lock();
3178 let Some(scenario) = BehindScenario::build() else {
3179 return; };
3181 let before = scenario.worktree_head();
3182 RebaseCommand {
3183 paths: vec![scenario.worktree.clone()],
3184 ..rebase_cmd()
3185 }
3186 .execute_with(None, |pending| async move {
3187 assert_eq!(pending, 1);
3188 true
3189 })
3190 .await
3191 .unwrap();
3192 assert_ne!(
3193 scenario.worktree_head(),
3194 before,
3195 "confirming the prompt must rebase the worktree"
3196 );
3197 }
3198
3199 struct BehindScenario {
3203 _root: tempfile::TempDir,
3204 worktree: PathBuf,
3205 }
3206
3207 impl BehindScenario {
3208 fn build() -> Option<Self> {
3209 use git2::Repository;
3210 let root = tempfile::tempdir().ok()?;
3211 let origin = root.path().join("origin.git");
3212 let local = root.path().join("local");
3213 let worktree = root.path().join("feature");
3214 std::fs::create_dir_all(&origin).ok()?;
3215 std::fs::create_dir_all(&local).ok()?;
3216 run(&origin, &["init", "--bare", "-b", "main"])?;
3217 run(&local, &["init", "-b", "main"])?;
3218 Self::identity(&local)?;
3219 std::fs::write(local.join("f.txt"), "one\n").ok()?;
3220 run(&local, &["add", "f.txt"])?;
3221 run(&local, &["commit", "-m", "one"])?;
3222 run(&local, &["remote", "add", "origin", origin.to_str()?])?;
3223 run(&local, &["push", "-u", "origin", "main"])?;
3224 run(
3225 &local,
3226 &[
3227 "worktree",
3228 "add",
3229 "-b",
3230 "feature",
3231 worktree.to_str()?,
3232 "main",
3233 ],
3234 )?;
3235 let repo = Repository::open_bare(&origin).ok()?;
3238 let parent = repo
3239 .find_commit(repo.refname_to_id("refs/heads/main").ok()?)
3240 .ok()?;
3241 let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
3242 let blob = repo.blob(b"two\n").ok()?;
3243 builder.insert("f.txt", blob, 0o100_644).ok()?;
3244 let tree = repo.find_tree(builder.write().ok()?).ok()?;
3245 let sig = git2::Signature::now("Other", "other@example.com").ok()?;
3246 repo.commit(
3247 Some("refs/heads/main"),
3248 &sig,
3249 &sig,
3250 "two",
3251 &tree,
3252 &[&parent],
3253 )
3254 .ok()?;
3255 Some(Self {
3256 _root: root,
3257 worktree,
3258 })
3259 }
3260
3261 fn identity(dir: &Path) -> Option<()> {
3264 run(dir, &["config", "user.name", "Test"])?;
3265 run(dir, &["config", "user.email", "test@example.com"])?;
3266 run(dir, &["config", "commit.gpgsign", "false"])
3267 }
3268
3269 fn worktree_head(&self) -> String {
3270 let out = std::process::Command::new("git")
3271 .current_dir(&self.worktree)
3272 .args(["rev-parse", "HEAD"])
3273 .output();
3274 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3275 .unwrap_or_default()
3276 }
3277 }
3278
3279 fn run(dir: &Path, args: &[&str]) -> Option<()> {
3281 let output = std::process::Command::new("git")
3282 .current_dir(dir)
3283 .args(args)
3284 .output()
3285 .ok()?;
3286 output.status.success().then_some(())
3287 }
3288
3289 #[test]
3292 fn merge_queue_parses_paths_and_flags() {
3293 assert!(matches!(
3295 parse(&["merge-queue", "/a"]),
3296 WorktreesSubcommands::MergeQueue(_)
3297 ));
3298 let cmd =
3300 MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
3301 assert_eq!(cmd.paths.len(), 2);
3302 assert!(cmd.check);
3303 assert!(!cmd.yes);
3304 assert!(cmd.socket.is_none());
3305 let cmd = MergeQueueCommand::try_parse_from([
3307 "merge-queue",
3308 "/a",
3309 "-y",
3310 "--socket",
3311 "/tmp/d.sock",
3312 ])
3313 .unwrap();
3314 assert!(cmd.yes);
3315 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
3316 assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
3318 }
3319
3320 #[test]
3321 fn render_eligibility_report_lists_eligible_and_skipped() {
3322 let report = json!({
3323 "eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
3324 "skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
3325 });
3326 let out = render_eligibility_report(&report);
3327 assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
3328 assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
3329 assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
3330 }
3331
3332 #[test]
3333 fn render_enqueue_result_marks_already_queued_and_failures() {
3334 let result = json!({
3335 "queued": [
3336 { "number": 10, "path": "/a" },
3337 { "number": 11, "path": "/b", "already_queued": true },
3338 ],
3339 "failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
3340 "skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
3341 });
3342 let out = render_enqueue_result(&result);
3343 assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
3344 assert!(out.contains("queued: PR #10"), "{out}");
3345 assert!(out.contains("PR #11 (already queued)"), "{out}");
3346 assert!(
3347 out.contains("failed: PR #12 — merge queue not enabled"),
3348 "{out}"
3349 );
3350 }
3351
3352 #[test]
3353 fn render_eligibility_report_strips_control_bytes() {
3354 let report = json!({
3357 "eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
3358 "skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
3359 });
3360 let out = render_eligibility_report(&report);
3361 assert!(
3362 !out.contains(|c: char| c.is_control() && c != '\n'),
3363 "{out:?}"
3364 );
3365 }
3366
3367 #[tokio::test]
3368 async fn confirm_enqueue_with_decides_from_the_answer() {
3369 assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
3370 assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
3371 assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
3372 assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
3373 assert!(!confirm_enqueue_with(3, async { None }).await);
3374 }
3375
3376 #[tokio::test]
3377 async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
3378 let cmd = MergeQueueCommand {
3381 paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
3382 check: true,
3383 yes: false,
3384 socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
3385 };
3386 let err = cmd.execute().await.unwrap_err();
3387 assert!(
3388 err.to_string().contains("cannot resolve worktree path"),
3389 "{err}"
3390 );
3391 }
3392
3393 #[tokio::test]
3394 async fn merge_queue_check_prints_the_report_and_never_confirms() {
3395 let target = tempfile::tempdir().unwrap();
3396 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3397 "ok": true,
3398 "payload": {
3399 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3400 "skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
3401 }
3402 })]);
3403 let cmd = MergeQueueCommand {
3404 paths: vec![target.path().to_path_buf()],
3405 check: true,
3406 yes: false,
3407 socket: Some(sock),
3408 };
3409 cmd.execute_with(|_| async { panic!("must not confirm on --check") })
3411 .await
3412 .unwrap();
3413 server.await.unwrap();
3414 }
3415
3416 #[tokio::test]
3417 async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
3418 let target = tempfile::tempdir().unwrap();
3419 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3420 "ok": true,
3421 "payload": {
3422 "eligible": [],
3423 "skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
3424 }
3425 })]);
3426 let cmd = MergeQueueCommand {
3427 paths: vec![target.path().to_path_buf()],
3428 check: false,
3429 yes: false,
3430 socket: Some(sock),
3431 };
3432 cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
3434 .await
3435 .unwrap();
3436 server.await.unwrap();
3437 }
3438
3439 #[tokio::test]
3440 async fn merge_queue_aborts_when_confirmation_is_declined() {
3441 let target = tempfile::tempdir().unwrap();
3442 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3443 "ok": true,
3444 "payload": {
3445 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3446 "skipped": [],
3447 }
3448 })]);
3449 let cmd = MergeQueueCommand {
3450 paths: vec![target.path().to_path_buf()],
3451 check: false,
3452 yes: false,
3453 socket: Some(sock),
3454 };
3455 cmd.execute_with(|count| async move {
3457 assert_eq!(count, 1);
3458 false
3459 })
3460 .await
3461 .unwrap();
3462 server.await.unwrap();
3463 }
3464
3465 #[tokio::test]
3466 async fn merge_queue_enqueues_after_confirmation() {
3467 let target = tempfile::tempdir().unwrap();
3468 let (_dir, sock, server) = fake_daemon_replies(vec![
3469 json!({
3470 "ok": true,
3471 "payload": {
3472 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3473 "skipped": [],
3474 }
3475 }),
3476 json!({
3477 "ok": true,
3478 "payload": {
3479 "queued": [{ "path": "/a", "number": 9 }],
3480 "skipped": [],
3481 "failed": [],
3482 }
3483 }),
3484 ]);
3485 let cmd = MergeQueueCommand {
3486 paths: vec![target.path().to_path_buf()],
3487 check: false,
3488 yes: false,
3489 socket: Some(sock),
3490 };
3491 cmd.execute_with(|_| async { true }).await.unwrap();
3493 server.await.unwrap();
3494 }
3495
3496 #[tokio::test]
3497 async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
3498 let target = tempfile::tempdir().unwrap();
3502 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3503 "ok": true,
3504 "payload": { "eligible": [], "skipped": [] }
3505 })]);
3506 let cmd = WorktreesCommand {
3507 command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
3508 paths: vec![target.path().to_path_buf()],
3509 check: true,
3510 yes: false,
3511 socket: Some(sock),
3512 }),
3513 };
3514 cmd.execute(None).await.unwrap();
3515 server.await.unwrap();
3516 }
3517
3518 #[test]
3521 fn reposition_parses_flags_and_enforces_the_undo_split() {
3522 let WorktreesSubcommands::Reposition(cmd) = parse(&[
3523 "reposition",
3524 "--reference",
3525 "/wt/ref",
3526 "/wt/a",
3527 "/wt/b",
3528 "--dry-run",
3529 "-o",
3530 "json",
3531 ]) else {
3532 panic!("expected the Reposition variant");
3533 };
3534 assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
3535 assert_eq!(
3536 cmd.paths,
3537 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3538 );
3539 assert!(cmd.dry_run);
3540 assert!(!cmd.undo);
3541 assert_eq!(cmd.output, TableOrJson::Json);
3542
3543 let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
3545 panic!("expected the Reposition variant");
3546 };
3547 assert!(undo.undo);
3548 assert!(undo.reference.is_none());
3549 }
3550
3551 #[test]
3552 fn reposition_rejects_a_missing_reference_and_undo_combinations() {
3553 assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
3556 assert!(RepositionCommand::try_parse_from([
3559 "reposition",
3560 "--undo",
3561 "--reference",
3562 "/wt/ref",
3563 ])
3564 .is_err());
3565 assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
3566 }
3567
3568 #[test]
3569 fn window_key_for_matches_a_canonicalized_folder() {
3570 let dir = tempfile::tempdir_in("/tmp").unwrap();
3571 let wt = dir.path().join("tree");
3572 std::fs::create_dir(&wt).unwrap();
3573 let canonical = std::fs::canonicalize(&wt).unwrap();
3574 let windows = json!({
3575 "windows": [
3576 { "key": "other", "folders": ["/definitely/not/here"] },
3577 { "key": "wanted", "folders": [canonical.to_string_lossy()] },
3578 ]
3579 });
3580 assert_eq!(
3581 window_key_for(&windows, &wt, "repositioned").unwrap(),
3582 "wanted"
3583 );
3584 }
3585
3586 #[test]
3587 fn window_key_for_errors_when_no_window_has_it_open() {
3588 let dir = tempfile::tempdir_in("/tmp").unwrap();
3592 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
3593 .expect_err("an unopened worktree must not resolve");
3594 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3595 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
3598 .expect_err("an unopened worktree must not resolve");
3599 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3600 let missing = dir.path().join("gone");
3602 let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
3603 .expect_err("a nonexistent path must not resolve");
3604 assert!(err.to_string().contains("cannot resolve"), "{err:#}");
3605 }
3606
3607 #[test]
3608 fn reload_command_requires_at_least_one_path() {
3609 assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
3612 let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
3613 assert_eq!(cmd.paths.len(), 2);
3614 assert!(matches!(cmd.output, TableOrJson::Table));
3615 assert!(cmd.socket.is_none());
3616 }
3617
3618 #[test]
3619 fn render_reload_reports_what_was_signalled_not_reloaded() {
3620 let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
3623 assert_eq!(out, "Signalled 2 of 2 windows to reload.");
3624 assert!(!out.contains("Reloaded"), "{out}");
3625 let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
3627 assert_eq!(one, "Signalled 1 of 1 window to reload.");
3628 }
3629
3630 #[test]
3631 fn render_reload_names_windows_that_had_already_closed() {
3632 let out = render_reload(&json!({
3635 "requested": 3,
3636 "signalled": 1,
3637 "unknown": ["w2", "w3"],
3638 }));
3639 assert!(
3640 out.starts_with("Signalled 1 of 3 windows to reload."),
3641 "{out}"
3642 );
3643 assert!(out.contains("No longer open"), "{out}");
3644 assert!(out.contains("w2, w3"), "{out}");
3645 }
3646
3647 #[test]
3648 fn render_reload_tolerates_a_reply_missing_every_field() {
3649 assert_eq!(
3652 render_reload(&json!({})),
3653 "Signalled 0 of 0 windows to reload."
3654 );
3655 }
3656
3657 #[test]
3658 fn render_reposition_explains_a_missing_permission() {
3659 let out = render_reposition(&json!({ "trusted": false, "results": [] }));
3660 assert!(out.contains("Accessibility permission"), "{out}");
3661 assert!(out.contains("daemon restart"), "{out}");
3662 }
3663
3664 #[test]
3665 fn render_reposition_reports_a_blocked_batch() {
3666 let out = render_reposition(&json!({
3667 "trusted": true,
3668 "blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
3669 "results": [],
3670 }));
3671 assert!(out.contains("Nothing was moved"), "{out}");
3672 assert!(out.contains("reference-ambiguous"), "{out}");
3673 assert!(out.contains("2 windows match"), "{out}");
3674 }
3675
3676 #[test]
3677 fn render_reposition_renders_the_reference_and_per_target_outcomes() {
3678 let out = render_reposition(&json!({
3679 "trusted": true,
3680 "reference": {
3681 "key": "r",
3682 "title": "ref-tree",
3683 "frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
3684 },
3685 "moved": 1,
3686 "skipped": 1,
3687 "results": [
3688 { "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
3689 { "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
3690 ],
3691 }));
3692 assert!(
3693 out.contains("Reference: ref-tree 800×600 at (10, 21)"),
3694 "{out}"
3695 );
3696 assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
3697 assert!(out.contains("moved: a-tree"), "{out}");
3698 assert!(out.contains("ambiguous: b-tree"), "{out}");
3699 }
3700
3701 #[test]
3702 fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
3703 let out = render_reposition(&json!({
3706 "trusted": true,
3707 "results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
3708 }));
3709 assert!(out.contains("no-window: keyless"), "{out}");
3710 let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
3712 assert!(empty.contains("(nothing to report)"), "{empty}");
3713 }
3714
3715 #[test]
3716 fn render_reposition_strips_control_bytes_from_daemon_strings() {
3717 let out = render_reposition(&json!({
3720 "trusted": true,
3721 "results": [{
3722 "key": "k",
3723 "title": "evil\u{1b}[31mred",
3724 "outcome": "moved",
3725 "detail": "ok\u{7}",
3726 }],
3727 }));
3728 assert!(!out.contains('\u{1b}'), "{out:?}");
3729 assert!(!out.contains('\u{7}'), "{out:?}");
3730 }
3731
3732 #[test]
3733 fn render_frame_formats_or_dashes() {
3734 assert_eq!(render_frame(None), "-");
3735 assert_eq!(
3736 render_frame(Some(
3737 &json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
3738 )),
3739 "100×50 at (2, -2)"
3740 );
3741 assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
3743 }
3744
3745 #[test]
3746 fn print_reposition_emits_both_formats() {
3747 let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
3748 print_reposition(TableOrJson::Table, &reply).unwrap();
3749 print_reposition(TableOrJson::Json, &reply).unwrap();
3750 }
3751
3752 #[tokio::test]
3753 async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
3754 let dir = tempfile::tempdir_in("/tmp").unwrap();
3755 let reference = dir.path().join("ref");
3756 let target = dir.path().join("tgt");
3757 std::fs::create_dir(&reference).unwrap();
3758 std::fs::create_dir(&target).unwrap();
3759 let (canon_ref, canon_tgt) = (
3760 std::fs::canonicalize(&reference).unwrap(),
3761 std::fs::canonicalize(&target).unwrap(),
3762 );
3763
3764 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
3767 json!({ "ok": true, "payload": { "windows": [
3768 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
3769 { "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
3770 ] } }),
3771 json!({ "ok": true, "payload": {
3772 "trusted": true,
3773 "moved": 1,
3774 "skipped": 0,
3775 "results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
3776 } }),
3777 ]);
3778
3779 RepositionCommand {
3780 paths: vec![target],
3781 reference: Some(reference),
3782 dry_run: false,
3783 undo: false,
3784 output: TableOrJson::Json,
3785 socket: Some(sock),
3786 }
3787 .execute()
3788 .await
3789 .unwrap();
3790 server.await.unwrap();
3791 }
3792
3793 #[tokio::test]
3794 async fn reposition_undo_skips_the_list_lookup_entirely() {
3795 let (_dir, sock, server) = fake_daemon_reply(json!({
3798 "ok": true,
3799 "payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
3800 }));
3801 RepositionCommand {
3802 paths: Vec::new(),
3803 reference: None,
3804 dry_run: false,
3805 undo: true,
3806 output: TableOrJson::Table,
3807 socket: Some(sock),
3808 }
3809 .execute()
3810 .await
3811 .unwrap();
3812 server.await.unwrap();
3813 }
3814
3815 #[tokio::test]
3816 async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
3817 let dir = tempfile::tempdir_in("/tmp").unwrap();
3818 let reference = dir.path().join("ref");
3819 let target = dir.path().join("tgt");
3820 std::fs::create_dir(&reference).unwrap();
3821 std::fs::create_dir(&target).unwrap();
3822 let canon_ref = std::fs::canonicalize(&reference).unwrap();
3823
3824 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
3827 "ok": true,
3828 "payload": { "windows": [
3829 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
3830 ] },
3831 }));
3832 let err = RepositionCommand {
3833 paths: vec![target],
3834 reference: Some(reference),
3835 dry_run: true,
3836 undo: false,
3837 output: TableOrJson::Table,
3838 socket: Some(sock),
3839 }
3840 .execute()
3841 .await
3842 .expect_err("an unopened target must abort the command");
3843 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3844 server.await.unwrap();
3845 }
3846
3847 #[tokio::test]
3848 async fn reposition_surfaces_a_daemon_error() {
3849 let (_dir, sock, server) = fake_daemon_reply(json!({
3850 "ok": false,
3851 "error": "unknown worktrees op: reposition",
3852 }));
3853 let err = RepositionCommand {
3854 paths: Vec::new(),
3855 reference: None,
3856 dry_run: false,
3857 undo: true,
3858 output: TableOrJson::Table,
3859 socket: Some(sock),
3860 }
3861 .execute()
3862 .await
3863 .expect_err("an `ok:false` reply must not be reported as success");
3864 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
3865 server.await.unwrap();
3866 }
3867
3868 fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
3871 let dir = tempfile::tempdir_in("/tmp").unwrap();
3872 let a = dir.path().join("a");
3873 let b = dir.path().join("b");
3874 std::fs::create_dir(&a).unwrap();
3875 std::fs::create_dir(&b).unwrap();
3876 let list = json!({ "ok": true, "payload": { "windows": [
3877 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
3878 { "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
3879 ] } });
3880 (dir, a, b, list)
3881 }
3882
3883 #[tokio::test]
3884 async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
3885 let (_dir, a, b, list) = reload_fixture();
3886 let (_sock_dir, sock, server) = fake_daemon_seq(vec![
3889 list,
3890 json!({ "ok": true, "payload": {
3891 "requested": 2, "signalled": 2, "unknown": [],
3892 } }),
3893 ]);
3894
3895 ReloadCommand {
3896 paths: vec![a, b],
3897 output: TableOrJson::Table,
3898 socket: Some(sock),
3899 }
3900 .execute()
3901 .await
3902 .unwrap();
3903
3904 let requests = server.await.unwrap();
3907 assert_eq!(requests[1]["op"], "reload");
3908 assert_eq!(
3909 requests[1]["payload"]["target_keys"],
3910 json!(["key-a", "key-b"])
3911 );
3912 assert!(
3913 requests[1]["payload"].get("requester_key").is_none(),
3914 "a CLI process is not a window, so it must not claim to be one"
3915 );
3916 }
3917
3918 #[tokio::test]
3919 async fn reload_json_output_passes_the_reply_through_verbatim() {
3920 let (_dir, a, _b, list) = reload_fixture();
3921 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
3922 list,
3923 json!({ "ok": true, "payload": {
3924 "requested": 1, "signalled": 0, "unknown": ["key-a"],
3925 } }),
3926 ]);
3927 ReloadCommand {
3930 paths: vec![a],
3931 output: TableOrJson::Json,
3932 socket: Some(sock),
3933 }
3934 .execute()
3935 .await
3936 .unwrap();
3937 server.await.unwrap();
3938 }
3939
3940 #[tokio::test]
3941 async fn reload_fails_before_the_op_when_a_target_has_no_window() {
3942 let (_dir, a, b, _list) = reload_fixture();
3943 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
3947 "ok": true,
3948 "payload": { "windows": [
3949 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
3950 ] },
3951 }));
3952 let err = ReloadCommand {
3953 paths: vec![a, b],
3954 output: TableOrJson::Table,
3955 socket: Some(sock),
3956 }
3957 .execute()
3958 .await
3959 .expect_err("an unopened target must abort the command");
3960 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3961 server.await.unwrap();
3962 }
3963
3964 #[tokio::test]
3965 async fn reload_surfaces_a_daemon_error() {
3966 let (_dir, a, _b, list) = reload_fixture();
3967 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
3968 list,
3969 json!({ "ok": false, "error": "unknown worktrees op: reload" }),
3970 ]);
3971 let err = ReloadCommand {
3974 paths: vec![a],
3975 output: TableOrJson::Table,
3976 socket: Some(sock),
3977 }
3978 .execute()
3979 .await
3980 .expect_err("an `ok:false` reply must not be reported as success");
3981 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
3982 server.await.unwrap();
3983 }
3984}