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::{sanitize_for_terminal, TableOrJson};
20use crate::daemon::client::DaemonClient;
21use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
22use crate::daemon::server;
23use crate::git::worktree_batch::Selection;
24use crate::git::worktree_push;
25use crate::git::worktree_rebase::{
26 self, FetchOutcome, RebaseOptions, RebaseResult, SkipReason, WorktreeOutcome,
27};
28
29const SERVICE: &str = "worktrees";
31
32#[derive(Parser)]
35pub struct WorktreesCommand {
36 #[command(subcommand)]
38 pub command: WorktreesSubcommands,
39}
40
41#[derive(Subcommand)]
43pub enum WorktreesSubcommands {
44 List(ListCommand),
46 Tree(TreeCommand),
48 Focus(FocusCommand),
50 Close(CloseCommand),
52 Rebase(RebaseCommand),
54 Push(PushCommand),
56 MergeQueue(MergeQueueCommand),
58 Reposition(RepositionCommand),
60 Reload(ReloadCommand),
62 ShowClosed(ShowClosedCommand),
64 Register(RegisterCommand),
66 Heartbeat(HeartbeatCommand),
68 Unregister(UnregisterCommand),
70}
71
72impl WorktreesCommand {
73 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
81 match self.command {
82 WorktreesSubcommands::List(cmd) => cmd.execute().await,
83 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
84 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
85 WorktreesSubcommands::Close(cmd) => cmd.execute().await,
86 WorktreesSubcommands::Rebase(cmd) => cmd.execute(repo).await,
87 WorktreesSubcommands::Push(cmd) => cmd.execute(repo).await,
88 WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
89 WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
90 WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
91 WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
92 WorktreesSubcommands::Register(cmd) => cmd.execute().await,
93 WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
94 WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
95 }
96 }
97}
98
99#[derive(Parser)]
101pub struct ListCommand {
102 #[arg(long, value_name = "PATH")]
104 pub socket: Option<PathBuf>,
105 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
107 pub output: TableOrJson,
108 #[arg(long, hide = true)]
110 pub json: bool,
111}
112
113impl ListCommand {
114 pub async fn execute(mut self) -> Result<()> {
116 if self.json {
117 eprintln!("warning: --json is deprecated; use -o/--output json instead");
118 self.output = TableOrJson::Json;
119 }
120 let socket = server::resolve_socket(self.socket)?;
121 let result = call(&socket, "list", Value::Null).await?;
122 match self.output {
123 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
124 TableOrJson::Table => println!("{}", render_windows(&result)),
125 }
126 Ok(())
127 }
128}
129
130#[derive(Parser)]
134pub struct TreeCommand {
135 #[arg(long, value_name = "PATH")]
137 pub socket: Option<PathBuf>,
138 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
140 pub output: TableOrJson,
141 #[arg(short = 'f', long)]
144 pub follow: bool,
145}
146
147impl TreeCommand {
148 pub async fn execute(self) -> Result<()> {
150 let socket = server::resolve_socket(self.socket)?;
151 if self.follow {
152 return follow_tree_stream(&socket, self.output).await;
153 }
154 let mut result = call(&socket, "tree", Value::Null).await?;
155 enrich_ahead_behind(&socket, &mut result).await;
161 match self.output {
162 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
163 TableOrJson::Table => println!("{}", render_tree(&result)),
164 }
165 Ok(())
166 }
167}
168
169async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
176 let mut sub = DaemonClient::new(socket)
177 .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
178 .await?;
179 loop {
180 tokio::select! {
181 frame = sub.next() => {
182 let Some(frame) = frame else { break };
184 let mut payload = reply_payload(frame?)?;
185 enrich_ahead_behind(socket, &mut payload).await;
189 match output {
190 TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
192 TableOrJson::Table => println!("{}", render_tree(&payload)),
193 }
194 }
195 _ = tokio::signal::ctrl_c() => break,
198 }
199 }
200 Ok(())
201}
202
203#[derive(Parser)]
210pub struct FocusCommand {
211 #[arg(value_name = "PATH")]
213 pub path: PathBuf,
214 #[arg(long, value_name = "PATH")]
216 pub socket: Option<PathBuf>,
217}
218
219impl FocusCommand {
220 pub async fn execute(self) -> Result<()> {
222 let path = std::fs::canonicalize(&self.path)
226 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
227 let socket = server::resolve_socket(self.socket)?;
228 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
229 println!("Focused {}", path.display());
230 Ok(())
231 }
232}
233
234#[derive(Parser)]
243pub struct CloseCommand {
244 #[arg(value_name = "PATH")]
247 pub path: PathBuf,
248 #[arg(long)]
250 pub window_only: bool,
251 #[arg(long)]
253 pub dry_run: bool,
254 #[arg(short = 'y', long)]
256 pub yes: bool,
257 #[arg(long, value_name = "PATH")]
259 pub socket: Option<PathBuf>,
260}
261
262impl CloseCommand {
263 pub async fn execute(self) -> Result<()> {
265 self.execute_with(confirm_removal).await
266 }
267
268 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
273 where
274 F: FnOnce(bool) -> Fut,
275 Fut: std::future::Future<Output = bool>,
276 {
277 let path = std::fs::canonicalize(&self.path)
280 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
281 let path_str = path.to_string_lossy().to_string();
282 let socket = server::resolve_socket(self.socket)?;
283
284 if self.window_only {
288 if self.dry_run {
289 println!(
290 "Would close the window for {} (dry run; nothing closed)",
291 path.display()
292 );
293 return Ok(());
294 }
295 call(
296 &socket,
297 "close",
298 json!({ "path": path_str, "remove": false }),
299 )
300 .await?;
301 println!("Closed the window for {}", path.display());
302 return Ok(());
303 }
304
305 let report = call(
307 &socket,
308 "close",
309 json!({ "path": path_str, "remove": true }),
310 )
311 .await?;
312 println!("{}", render_safety_report(&path, &report));
313
314 if self.dry_run {
315 return Ok(());
316 }
317 if report.get("removable").and_then(Value::as_bool) != Some(true) {
320 bail!(
321 "{} is not a removable worktree (nothing deleted); \
322 use --window-only to just close its window",
323 path.display()
324 );
325 }
326 let has_risks = report
327 .get("risks")
328 .and_then(Value::as_array)
329 .is_some_and(|r| !r.is_empty());
330 if !self.yes && !confirm(has_risks).await {
331 println!("Aborted; nothing was deleted.");
332 return Ok(());
333 }
334
335 call(
337 &socket,
338 "close",
339 json!({ "path": path_str, "remove": true, "confirmed": true }),
340 )
341 .await?;
342 println!("Deleted worktree {}", path.display());
343 Ok(())
344 }
345}
346
347#[derive(Parser)]
361pub struct RebaseCommand {
362 #[arg(value_name = "PATH")]
365 pub paths: Vec<PathBuf>,
366 #[arg(long)]
369 pub all: bool,
370 #[arg(long, value_name = "REF")]
373 pub onto: Option<String>,
374 #[arg(long)]
377 pub autostash: bool,
378 #[arg(long)]
380 pub dry_run: bool,
381 #[arg(long)]
384 pub keep_conflicts: bool,
385 #[arg(short = 'y', long)]
387 pub yes: bool,
388 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
390 pub output: TableOrJson,
391}
392
393impl RebaseCommand {
394 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
396 self.execute_with(repo, confirm_rebase).await
397 }
398
399 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
404 where
405 F: FnOnce(usize) -> Fut,
406 Fut: std::future::Future<Output = bool>,
407 {
408 let selection = self.selection(repo)?;
409 let opts = RebaseOptions {
410 onto: self.onto.clone(),
411 autostash: self.autostash,
412 dry_run: self.dry_run,
413 keep_conflicts: self.keep_conflicts,
414 git_bin: None,
418 };
419
420 let plan_opts = opts.clone();
423 let plan =
424 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
425 .await
426 .context("rebase planning task panicked")??;
427
428 let json = matches!(self.output, TableOrJson::Json);
429 if self.dry_run || !plan.has_pending_rebases() {
433 self.print(json, &plan.fetches, &plan.worktrees)?;
434 return Ok(());
435 }
436
437 if !json {
439 println!("{}", render_fetches(&plan.fetches));
440 println!("{}", render_outcomes(&plan.worktrees));
441 }
442 let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
443 if !self.yes && !confirm(pending).await {
444 println!("Aborted; no worktree was rebased.");
445 return Ok(());
446 }
447
448 let fetches = plan.fetches.clone();
449 let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
450 .await
451 .context("rebase task panicked")?;
452 if !json {
453 println!();
454 }
455 self.print(json, &fetches, &outcomes)
456 }
457
458 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
466 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
467 if self.all {
468 if !self.paths.is_empty() {
469 bail!("pass either <PATH>... or --all, not both");
470 }
471 return Ok(Selection::All { base });
472 }
473 if self.paths.is_empty() {
474 bail!(
475 "specify one or more <PATH> arguments, or --all to rebase \
476 every worktree of this repository"
477 );
478 }
479 let paths = self
480 .paths
481 .iter()
482 .map(|path| {
483 if path.is_absolute() {
484 path.clone()
485 } else {
486 base.join(path)
487 }
488 })
489 .collect();
490 Ok(Selection::Paths(paths))
491 }
492
493 fn print(
495 &self,
496 json: bool,
497 fetches: &[FetchOutcome],
498 outcomes: &[WorktreeOutcome],
499 ) -> Result<()> {
500 if json {
501 let value = json!({
502 "dry_run": self.dry_run,
503 "fetches": fetches,
504 "worktrees": outcomes,
505 });
506 println!("{}", serde_json::to_string_pretty(&value)?);
507 } else {
508 println!("{}", render_fetches(fetches));
509 println!("{}", render_outcomes(outcomes));
510 }
511 Ok(())
512 }
513}
514
515fn is_pending(outcome: &WorktreeOutcome) -> bool {
517 matches!(outcome.result, RebaseResult::WouldRebase { .. })
518}
519
520fn render_fetches(fetches: &[FetchOutcome]) -> String {
523 if fetches.is_empty() {
524 return "No repository selected.".to_string();
525 }
526 fetches
527 .iter()
528 .map(fetch_line)
529 .collect::<Vec<_>>()
530 .join("\n")
531}
532
533fn fetch_line(fetch: &FetchOutcome) -> String {
535 let root = sanitize(&fetch.repo_root.display().to_string());
536 let onto = sanitize(&fetch.onto);
537 if !fetch.fetched {
538 return format!("Using {onto} in {root} (local ref; nothing fetched)");
539 }
540 if fetch.ok {
541 format!("Fetched {onto} once for {root}")
542 } else {
543 let detail = brief(fetch.detail.as_deref().unwrap_or(""));
544 format!("Fetch of {onto} FAILED for {root}: {detail}")
545 }
546}
547
548fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
550 if outcomes.is_empty() {
551 return "No worktrees selected.".to_string();
552 }
553 let mut out = format!(
554 "{:<12} {:<24} {:<16} {}",
555 "STATUS", "BRANCH", "ONTO", "WORKTREE"
556 );
557 for outcome in outcomes {
558 out.push('\n');
559 out.push_str(&outcome_row(outcome));
560 }
561 out
562}
563
564fn outcome_row(outcome: &WorktreeOutcome) -> String {
566 let (status, detail) = status_and_detail(&outcome.result);
567 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
568 let onto = sanitize(&outcome.onto);
569 let path = sanitize(&outcome.path.display().to_string());
570 let suffix = if detail.is_empty() {
571 String::new()
572 } else {
573 format!(" ({detail})")
574 };
575 format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
576}
577
578fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
580 match result {
581 RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
582 RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
583 RebaseResult::UpToDate => ("up-to-date", String::new()),
584 RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
585 RebaseResult::Conflict {
589 detail,
590 left_in_place: true,
591 } => (
592 "conflict",
593 format!(
594 "left in place; resolve then `git rebase --continue`: {}",
595 brief(detail)
596 ),
597 ),
598 RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
599 RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
600 }
601}
602
603fn skip_reason_text(reason: SkipReason) -> &'static str {
605 match reason {
606 SkipReason::DetachedHead => "detached HEAD",
607 SkipReason::Dirty => "uncommitted changes; pass --autostash",
608 SkipReason::OperationInProgress => "a rebase/merge is already in progress",
609 SkipReason::NotAWorktree => "not a git worktree",
610 SkipReason::NoOntoRef => "could not resolve the target ref",
611 }
612}
613
614fn brief(detail: &str) -> String {
617 let first = detail
618 .lines()
619 .find(|line| !line.trim().is_empty())
620 .unwrap_or("");
621 let clean = sanitize(first.trim());
622 if clean.chars().count() > 100 {
623 let truncated: String = clean.chars().take(97).collect();
624 format!("{truncated}...")
625 } else {
626 clean
627 }
628}
629
630async fn confirm_rebase(pending: usize) -> bool {
632 confirm_rebase_with(pending, read_stdin_line()).await
633}
634
635async fn confirm_rebase_with(
639 pending: usize,
640 read: impl std::future::Future<Output = Option<String>>,
641) -> bool {
642 use std::io::Write;
643 eprint!("{}", rebase_prompt(pending));
644 let _ = std::io::stderr().flush();
645 read.await.as_deref().is_some_and(answer_is_yes)
646}
647
648fn rebase_prompt(pending: usize) -> String {
651 let noun = if pending == 1 {
652 "worktree"
653 } else {
654 "worktrees"
655 };
656 format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
657}
658
659#[derive(Parser)]
679pub struct PushCommand {
680 #[arg(value_name = "PATH")]
683 pub paths: Vec<PathBuf>,
684 #[arg(long)]
687 pub all: bool,
688 #[arg(long)]
690 pub dry_run: bool,
691 #[arg(short = 'y', long)]
693 pub yes: bool,
694 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
696 pub output: TableOrJson,
697}
698
699impl PushCommand {
700 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
702 self.execute_with(repo, confirm_push).await
703 }
704
705 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
710 where
711 F: FnOnce(usize, usize) -> Fut,
712 Fut: std::future::Future<Output = bool>,
713 {
714 let selection = self.selection(repo)?;
715
716 let plan = tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
721 .await
722 .context("push planning task panicked")??;
723
724 let json = matches!(self.output, TableOrJson::Json);
725 if self.dry_run || !plan.has_pending_pushes() {
727 return self.print(json, &plan.worktrees);
728 }
729
730 if !json {
733 println!("{}", render_push_outcomes(&plan.worktrees));
734 }
735 let pending = plan
736 .worktrees
737 .iter()
738 .filter(|w| w.result.is_pending())
739 .count();
740 let forced = plan
741 .worktrees
742 .iter()
743 .filter(|w| matches!(w.result, worktree_push::PushResult::WouldForce { .. }))
744 .count();
745 if !self.yes && !confirm(pending, forced).await {
746 println!("Aborted; nothing was pushed.");
747 return Ok(());
748 }
749
750 let opts = worktree_push::PushOptions::default();
754 let outcomes = tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts))
755 .await
756 .context("push task panicked")?;
757 if !json {
758 println!();
759 }
760 self.print(json, &outcomes)
761 }
762
763 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
766 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
767 if self.all {
768 if !self.paths.is_empty() {
769 bail!("pass either <PATH>... or --all, not both");
770 }
771 return Ok(Selection::All { base });
772 }
773 if self.paths.is_empty() {
774 bail!(
775 "specify one or more <PATH> arguments, or --all to publish \
776 every worktree of this repository"
777 );
778 }
779 let paths = self
780 .paths
781 .iter()
782 .map(|path| {
783 if path.is_absolute() {
784 path.clone()
785 } else {
786 base.join(path)
787 }
788 })
789 .collect();
790 Ok(Selection::Paths(paths))
791 }
792
793 fn print(&self, json: bool, outcomes: &[worktree_push::WorktreeOutcome]) -> Result<()> {
795 if json {
796 let value = json!({ "dry_run": self.dry_run, "worktrees": outcomes });
797 println!("{}", serde_json::to_string_pretty(&value)?);
798 } else {
799 println!("{}", render_push_outcomes(outcomes));
800 }
801 Ok(())
802 }
803}
804
805fn render_push_outcomes(outcomes: &[worktree_push::WorktreeOutcome]) -> String {
807 if outcomes.is_empty() {
808 return "No worktrees selected.".to_string();
809 }
810 let mut out = format!(
811 "{:<14} {:<24} {:<20} {}",
812 "STATUS", "BRANCH", "REMOTE", "WORKTREE"
813 );
814 for outcome in outcomes {
815 out.push('\n');
816 out.push_str(&push_outcome_row(outcome));
817 }
818 out
819}
820
821fn push_outcome_row(outcome: &worktree_push::WorktreeOutcome) -> String {
823 let (status, detail) = push_status_and_detail(&outcome.result);
824 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
825 let destination = if outcome.remote.is_empty() {
826 "-".to_string()
827 } else {
828 sanitize(&format!("{}/{}", outcome.remote, outcome.remote_branch))
829 };
830 let path = sanitize(&outcome.path.display().to_string());
831 let suffix = if detail.is_empty() {
832 String::new()
833 } else {
834 format!(" ({detail})")
835 };
836 format!("{status:<14} {branch:<24} {destination:<20} {path}{suffix}")
837}
838
839fn push_status_and_detail(result: &worktree_push::PushResult) -> (&'static str, String) {
841 use worktree_push::PushResult;
842 match result {
843 PushResult::UpToDate => ("up-to-date", String::new()),
844 PushResult::WouldFastForward { ahead } => {
845 ("would-push", format!("{ahead} ahead; fast-forward"))
846 }
847 PushResult::WouldForce { ahead, behind } => (
848 "would-force",
849 format!("{ahead} ahead, {behind} behind; needs --force-with-lease"),
850 ),
851 PushResult::WouldCreate => ("would-create", "no upstream yet".to_string()),
852 PushResult::Pushed { forced: true } => ("pushed", "forced with lease".to_string()),
853 PushResult::Pushed { forced: false } => ("pushed", "fast-forward".to_string()),
854 PushResult::Created => ("created", "upstream set".to_string()),
855 PushResult::Rejected { detail, stale: true } => (
859 "rejected",
860 format!(
861 "the remote moved since you last fetched; run `git fetch` and rebase, then retry: {}",
862 brief(detail)
863 ),
864 ),
865 PushResult::Rejected { detail, .. } => ("rejected", brief(detail)),
866 PushResult::Skipped { reason } => ("skipped", push_skip_reason_text(*reason).to_string()),
867 }
868}
869
870fn push_skip_reason_text(reason: worktree_push::SkipReason) -> &'static str {
872 use worktree_push::SkipReason;
873 match reason {
874 SkipReason::DetachedHead => "detached HEAD",
875 SkipReason::NotAWorktree => "not a git worktree",
876 SkipReason::NoRemote => "no remote to publish to",
877 SkipReason::DefaultBranchForcePush => {
878 "refusing to force-push the remote default branch; \
879 fast-forward it or open a PR instead"
880 }
881 }
882}
883
884async fn confirm_push(pending: usize, forced: usize) -> bool {
886 confirm_push_with(pending, forced, read_stdin_line()).await
887}
888
889async fn confirm_push_with(
893 pending: usize,
894 forced: usize,
895 read: impl std::future::Future<Output = Option<String>>,
896) -> bool {
897 use std::io::Write;
898 eprint!("{}", push_prompt(pending, forced));
899 let _ = std::io::stderr().flush();
900 read.await.as_deref().is_some_and(answer_is_yes)
901}
902
903fn push_prompt(pending: usize, forced: usize) -> String {
909 let noun = if pending == 1 { "branch" } else { "branches" };
910 if forced == 0 {
911 return format!("Push {pending} {noun}? [y/N] ");
912 }
913 let forced_noun = if forced == 1 { "one" } else { "them" };
914 format!(
915 "Push {pending} {noun}, force-pushing {forced} with a lease \
916 (this publishes rewritten history — anyone who has {forced_noun} \
917 will need to reset)? [y/N] "
918 )
919}
920
921#[derive(Parser)]
930pub struct MergeQueueCommand {
931 #[arg(value_name = "PATH", required = true)]
934 pub paths: Vec<PathBuf>,
935 #[arg(long)]
937 pub check: bool,
938 #[arg(short = 'y', long)]
940 pub yes: bool,
941 #[arg(long, value_name = "PATH")]
943 pub socket: Option<PathBuf>,
944}
945
946impl MergeQueueCommand {
947 pub async fn execute(self) -> Result<()> {
950 self.execute_with(confirm_enqueue).await
951 }
952
953 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
958 where
959 F: FnOnce(usize) -> Fut,
960 Fut: std::future::Future<Output = bool>,
961 {
962 let mut paths = Vec::with_capacity(self.paths.len());
965 for p in &self.paths {
966 let abs = std::fs::canonicalize(p)
967 .with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
968 paths.push(abs.to_string_lossy().to_string());
969 }
970 let socket = server::resolve_socket(self.socket)?;
971
972 let report = call(
974 &socket,
975 "merge-queue",
976 json!({ "paths": paths, "check": true }),
977 )
978 .await?;
979 println!("{}", render_eligibility_report(&report));
980
981 if self.check {
982 return Ok(());
983 }
984 let eligible = report
985 .get("eligible")
986 .and_then(Value::as_array)
987 .map_or(0, Vec::len);
988 if eligible == 0 {
989 println!("Nothing to enqueue.");
990 return Ok(());
991 }
992 if !self.yes && !confirm(eligible).await {
993 println!("Aborted; nothing was enqueued.");
994 return Ok(());
995 }
996
997 let result = call(
999 &socket,
1000 "merge-queue",
1001 json!({ "paths": paths, "confirmed": true }),
1002 )
1003 .await?;
1004 println!("{}", render_enqueue_result(&result));
1005 Ok(())
1006 }
1007}
1008
1009#[derive(Parser)]
1022pub struct RepositionCommand {
1023 #[arg(value_name = "PATH")]
1026 pub paths: Vec<PathBuf>,
1027 #[arg(
1030 long,
1031 value_name = "PATH",
1032 required_unless_present = "undo",
1033 conflicts_with = "undo"
1034 )]
1035 pub reference: Option<PathBuf>,
1036 #[arg(long, conflicts_with = "undo")]
1038 pub dry_run: bool,
1039 #[arg(long)]
1041 pub undo: bool,
1042 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1044 pub output: TableOrJson,
1045 #[arg(long, value_name = "PATH")]
1047 pub socket: Option<PathBuf>,
1048}
1049
1050impl RepositionCommand {
1051 pub async fn execute(self) -> Result<()> {
1053 let output = self.output;
1054 let socket = server::resolve_socket(self.socket)?;
1055 if self.undo {
1056 let reply = call(&socket, "reposition-undo", Value::Null).await?;
1057 return print_reposition(output, &reply);
1058 }
1059 let Some(reference) = self.reference.as_deref() else {
1061 bail!("`reposition` requires `--reference <PATH>`");
1062 };
1063
1064 let windows = call(&socket, "list", Value::Null).await?;
1068 let reference_key = window_key_for(&windows, reference, "repositioned")?;
1069 let mut target_keys = Vec::with_capacity(self.paths.len());
1070 for path in &self.paths {
1071 target_keys.push(window_key_for(&windows, path, "repositioned")?);
1072 }
1073
1074 let reply = call(
1075 &socket,
1076 "reposition",
1077 json!({
1078 "reference_key": reference_key,
1079 "target_keys": target_keys,
1080 "check": self.dry_run,
1081 }),
1082 )
1083 .await?;
1084 print_reposition(output, &reply)
1085 }
1086}
1087
1088fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
1090 match output {
1091 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
1092 TableOrJson::Table => println!("{}", render_reposition(reply)),
1093 }
1094 Ok(())
1095}
1096
1097#[derive(Parser)]
1112pub struct ReloadCommand {
1113 #[arg(value_name = "PATH", required = true)]
1117 pub paths: Vec<PathBuf>,
1118 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1120 pub output: TableOrJson,
1121 #[arg(long, value_name = "PATH")]
1123 pub socket: Option<PathBuf>,
1124}
1125
1126impl ReloadCommand {
1127 pub async fn execute(self) -> Result<()> {
1129 let socket = server::resolve_socket(self.socket)?;
1130 let windows = call(&socket, "list", Value::Null).await?;
1131 let mut target_keys = Vec::with_capacity(self.paths.len());
1132 for path in &self.paths {
1133 target_keys.push(window_key_for(&windows, path, "reloaded")?);
1134 }
1135
1136 let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
1137 match self.output {
1138 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
1139 TableOrJson::Table => println!("{}", render_reload(&reply)),
1140 }
1141 Ok(())
1142 }
1143}
1144
1145fn render_reload(reply: &Value) -> String {
1152 let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
1153 let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
1154 let unknown: Vec<String> = reply
1155 .get("unknown")
1156 .and_then(Value::as_array)
1157 .map(|keys| {
1158 keys.iter()
1159 .filter_map(Value::as_str)
1160 .map(sanitize)
1161 .collect()
1162 })
1163 .unwrap_or_default();
1164
1165 let noun = if requested == 1 { "window" } else { "windows" };
1168 let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
1169 if !unknown.is_empty() {
1170 out.push_str(&format!(
1173 "\nNo longer open, so not signalled: {}",
1174 unknown.join(", ")
1175 ));
1176 }
1177 out
1178}
1179
1180fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
1191 let wanted = std::fs::canonicalize(path)
1192 .with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
1193 windows
1194 .get("windows")
1195 .and_then(Value::as_array)
1196 .map(Vec::as_slice)
1197 .unwrap_or_default()
1198 .iter()
1199 .find(|window| {
1200 window
1201 .get("folders")
1202 .and_then(Value::as_array)
1203 .is_some_and(|folders| {
1204 folders.iter().filter_map(Value::as_str).any(|folder| {
1205 std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
1206 })
1207 })
1208 })
1209 .and_then(|window| window.get("key").and_then(Value::as_str))
1210 .map(ToString::to_string)
1211 .ok_or_else(|| {
1212 anyhow::anyhow!(
1213 "no VS Code window has {} open (only open windows can be {verb})",
1214 wanted.display()
1215 )
1216 })
1217}
1218
1219fn render_reposition(reply: &Value) -> String {
1223 if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
1224 return "omni-dev does not hold the macOS Accessibility permission, so no window \
1225 was touched.\nGrant it in System Settings → Privacy & Security → \
1226 Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
1227 .to_string();
1228 }
1229 if let Some(blocked) = reply.get("blocked") {
1230 let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
1231 let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
1232 return format!("Nothing was moved [{reason}]: {detail}");
1233 }
1234
1235 let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
1236 let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
1237 let mut out = String::new();
1238 if let Some(reference) = reply.get("reference") {
1239 let title = sanitize(
1240 reference
1241 .get("title")
1242 .and_then(Value::as_str)
1243 .unwrap_or("-"),
1244 );
1245 out.push_str(&format!(
1246 "Reference: {title} {}\n",
1247 render_frame(reference.get("frame"))
1248 ));
1249 }
1250 out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
1251 let results = reply
1252 .get("results")
1253 .and_then(Value::as_array)
1254 .map(Vec::as_slice)
1255 .unwrap_or_default();
1256 for result in results {
1257 let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
1258 let title = sanitize(
1259 result
1260 .get("title")
1261 .and_then(Value::as_str)
1262 .or_else(|| result.get("key").and_then(Value::as_str))
1263 .unwrap_or("-"),
1264 );
1265 let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
1266 out.push_str(&format!("\n {outcome}: {title} — {detail}"));
1267 }
1268 if results.is_empty() {
1269 out.push_str("\n (nothing to report)");
1270 }
1271 out
1272}
1273
1274fn render_frame(frame: Option<&Value>) -> String {
1276 let Some(frame) = frame else {
1277 return "-".to_string();
1278 };
1279 let field = |name: &str| {
1280 frame
1281 .get(name)
1282 .and_then(Value::as_f64)
1283 .unwrap_or(0.0)
1284 .round()
1285 };
1286 format!(
1287 "{}×{} at ({}, {})",
1288 field("width"),
1289 field("height"),
1290 field("x"),
1291 field("y")
1292 )
1293}
1294
1295#[derive(Parser)]
1301pub struct ShowClosedCommand {
1302 #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
1304 pub value: Option<bool>,
1305 #[arg(long, value_name = "PATH")]
1307 pub socket: Option<PathBuf>,
1308}
1309
1310impl ShowClosedCommand {
1311 pub async fn execute(self) -> Result<()> {
1313 let socket = server::resolve_socket(self.socket)?;
1314 if let Some(show_closed) = self.value {
1315 call(
1316 &socket,
1317 "set-show-closed",
1318 json!({ "show_closed": show_closed }),
1319 )
1320 .await?;
1321 println!("show-closed: {show_closed}");
1322 } else {
1323 let tree = call(&socket, "tree", Value::Null).await?;
1325 let current = tree
1326 .get("show_closed")
1327 .and_then(Value::as_bool)
1328 .unwrap_or(true);
1329 println!("show-closed: {current}");
1330 }
1331 Ok(())
1332 }
1333}
1334
1335#[derive(Parser)]
1341pub struct RegisterCommand {
1342 #[arg(long, value_name = "KEY")]
1344 pub key: String,
1345 #[arg(long = "folder", value_name = "PATH")]
1347 pub folders: Vec<PathBuf>,
1348 #[arg(long, value_name = "REPO")]
1356 pub repo_name: Option<String>,
1357 #[arg(long, value_name = "TITLE")]
1359 pub title: Option<String>,
1360 #[arg(long, value_name = "PID")]
1362 pub pid: Option<u32>,
1363 #[arg(long, value_name = "PATH")]
1365 pub socket: Option<PathBuf>,
1366}
1367
1368impl RegisterCommand {
1369 pub async fn execute(self) -> Result<()> {
1371 let socket = server::resolve_socket(self.socket)?;
1372 let payload = json!({
1373 "key": self.key,
1374 "folders": self.folders,
1375 "repo": self.repo_name,
1376 "title": self.title,
1377 "pid": self.pid,
1378 });
1379 call(&socket, "register", payload).await?;
1380 println!("Registered {}", self.key);
1381 Ok(())
1382 }
1383}
1384
1385#[derive(Parser)]
1392pub struct HeartbeatCommand {
1393 #[arg(long, value_name = "KEY")]
1395 pub key: String,
1396 #[arg(long, value_name = "PATH")]
1398 pub socket: Option<PathBuf>,
1399}
1400
1401impl HeartbeatCommand {
1402 pub async fn execute(self) -> Result<()> {
1404 let socket = server::resolve_socket(self.socket)?;
1405 let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
1406 let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
1407 let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
1410 let reload = reply
1411 .get("reload")
1412 .and_then(Value::as_bool)
1413 .unwrap_or(false);
1414 println!("known: {known}");
1415 println!("close: {close}");
1416 println!("reload: {reload}");
1417 Ok(())
1418 }
1419}
1420
1421#[derive(Parser)]
1424pub struct UnregisterCommand {
1425 #[arg(long, value_name = "KEY")]
1427 pub key: String,
1428 #[arg(long, value_name = "PATH")]
1430 pub socket: Option<PathBuf>,
1431}
1432
1433impl UnregisterCommand {
1434 pub async fn execute(self) -> Result<()> {
1436 let socket = server::resolve_socket(self.socket)?;
1437 let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
1438 let removed = reply
1439 .get("removed")
1440 .and_then(Value::as_bool)
1441 .unwrap_or(false);
1442 println!("removed: {removed}");
1443 Ok(())
1444 }
1445}
1446
1447fn render_safety_report(path: &Path, report: &Value) -> String {
1452 let removable = report
1453 .get("removable")
1454 .and_then(Value::as_bool)
1455 .unwrap_or(false);
1456 let is_main = report
1457 .get("is_main")
1458 .and_then(Value::as_bool)
1459 .unwrap_or(false);
1460 let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
1461 let mut out = format!("Worktree: {}", path.display());
1462 out.push_str(&format!("\n removable: {removable}"));
1463 out.push_str(&format!("\n main working tree: {is_main}"));
1464 if open {
1465 let key = sanitize(
1466 report
1467 .get("window_key")
1468 .and_then(Value::as_str)
1469 .unwrap_or("-"),
1470 );
1471 let count = report
1472 .get("window_folder_count")
1473 .and_then(Value::as_u64)
1474 .unwrap_or(0);
1475 out.push_str(&format!(
1476 "\n open in a window: yes (key {key}, {count} folder(s))"
1477 ));
1478 } else {
1479 out.push_str("\n open in a window: no");
1480 }
1481 out.push_str(&render_notes("risks", report.get("risks")));
1482 out.push_str(&render_notes("info", report.get("info")));
1483 out
1484}
1485
1486fn render_notes(label: &str, notes: Option<&Value>) -> String {
1489 let notes = notes
1490 .and_then(Value::as_array)
1491 .map(Vec::as_slice)
1492 .unwrap_or_default();
1493 if notes.is_empty() {
1494 return String::new();
1495 }
1496 let mut out = format!("\n {label}:");
1497 for note in notes {
1498 let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
1499 let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
1500 out.push_str(&format!("\n - [{kind}] {detail}"));
1501 }
1502 out
1503}
1504
1505fn render_eligibility_report(report: &Value) -> String {
1510 let eligible = report
1511 .get("eligible")
1512 .and_then(Value::as_array)
1513 .map(Vec::as_slice)
1514 .unwrap_or_default();
1515 let skipped = report
1516 .get("skipped")
1517 .and_then(Value::as_array)
1518 .map(Vec::as_slice)
1519 .unwrap_or_default();
1520 let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
1521 for pr in eligible {
1522 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1523 let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
1524 let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
1525 out.push_str(&format!("\n eligible: PR #{number} [{branch}] {path}"));
1526 }
1527 for skip in skipped {
1528 let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
1529 let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
1530 let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
1531 out.push_str(&format!("\n skipped [{kind}]: {path} — {detail}"));
1532 }
1533 out
1534}
1535
1536fn render_enqueue_result(result: &Value) -> String {
1541 let queued = result
1542 .get("queued")
1543 .and_then(Value::as_array)
1544 .map(Vec::as_slice)
1545 .unwrap_or_default();
1546 let failed = result
1547 .get("failed")
1548 .and_then(Value::as_array)
1549 .map(Vec::as_slice)
1550 .unwrap_or_default();
1551 let skipped = result
1552 .get("skipped")
1553 .and_then(Value::as_array)
1554 .map_or(0, Vec::len);
1555 let mut out = format!(
1556 "Queued: {} / Failed: {} / Skipped: {}",
1557 queued.len(),
1558 failed.len(),
1559 skipped
1560 );
1561 for pr in queued {
1562 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1563 let already = pr
1564 .get("already_queued")
1565 .and_then(Value::as_bool)
1566 .unwrap_or(false);
1567 let suffix = if already { " (already queued)" } else { "" };
1568 out.push_str(&format!("\n queued: PR #{number}{suffix}"));
1569 }
1570 for pr in failed {
1571 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1572 let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
1573 out.push_str(&format!("\n failed: PR #{number} — {error}"));
1574 }
1575 out
1576}
1577
1578async fn confirm_removal(has_risks: bool) -> bool {
1585 confirm_removal_with(has_risks, read_stdin_line()).await
1586}
1587
1588async fn confirm_removal_with(
1593 has_risks: bool,
1594 read: impl std::future::Future<Output = Option<String>>,
1595) -> bool {
1596 use std::io::Write;
1597 eprint!("{}", confirm_prompt(has_risks));
1598 let _ = std::io::stderr().flush();
1599 read.await.as_deref().is_some_and(answer_is_yes)
1600}
1601
1602async fn confirm_enqueue(count: usize) -> bool {
1606 confirm_enqueue_with(count, read_stdin_line()).await
1607}
1608
1609async fn confirm_enqueue_with(
1613 count: usize,
1614 read: impl std::future::Future<Output = Option<String>>,
1615) -> bool {
1616 use std::io::Write;
1617 eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
1618 let _ = std::io::stderr().flush();
1619 read.await.as_deref().is_some_and(answer_is_yes)
1620}
1621
1622async fn read_stdin_line() -> Option<String> {
1626 tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
1627 .await
1628 .ok()
1629 .flatten()
1630}
1631
1632fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
1637 let mut answer = String::new();
1638 reader.read_line(&mut answer).ok().map(|_| answer)
1639}
1640
1641fn confirm_prompt(has_risks: bool) -> &'static str {
1644 if has_risks {
1645 "Delete this worktree despite the risks above? [y/N] "
1646 } else {
1647 "Delete this worktree? [y/N] "
1648 }
1649}
1650
1651fn answer_is_yes(answer: &str) -> bool {
1654 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
1655}
1656
1657async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
1664 let paths = worktree_paths(result);
1665 if paths.is_empty() {
1666 return;
1667 }
1668 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
1669 return;
1670 };
1671 if let Some(results) = reply.get("results").and_then(Value::as_object) {
1672 merge_ahead_behind(result, results);
1673 }
1674}
1675
1676fn worktree_paths(result: &Value) -> Vec<String> {
1679 let mut paths = Vec::new();
1680 for repo in result
1681 .get("repos")
1682 .and_then(Value::as_array)
1683 .map(Vec::as_slice)
1684 .unwrap_or_default()
1685 {
1686 for worktree in repo
1687 .get("worktrees")
1688 .and_then(Value::as_array)
1689 .map(Vec::as_slice)
1690 .unwrap_or_default()
1691 {
1692 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
1693 paths.push(path.to_string());
1694 }
1695 }
1696 }
1697 paths
1698}
1699
1700fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
1705 for repo in result
1706 .get_mut("repos")
1707 .and_then(Value::as_array_mut)
1708 .into_iter()
1709 .flatten()
1710 {
1711 for worktree in repo
1712 .get_mut("worktrees")
1713 .and_then(Value::as_array_mut)
1714 .into_iter()
1715 .flatten()
1716 {
1717 let Some(obj) = worktree.as_object_mut() else {
1721 continue;
1722 };
1723 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
1724 continue;
1725 };
1726 let Some(counts) = results.get(&path) else {
1727 continue;
1728 };
1729 if let (Some(ahead), Some(behind)) =
1732 (counts.get("ahead").cloned(), counts.get("behind").cloned())
1733 {
1734 obj.insert("ahead".to_string(), ahead);
1735 obj.insert("behind".to_string(), behind);
1736 }
1737 if let Some(main_behind) = counts.get("main_behind").cloned() {
1742 obj.insert("main_behind".to_string(), main_behind);
1743 }
1744 }
1745 }
1746}
1747
1748async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1751 let reply = DaemonClient::new(socket)
1752 .request(DaemonEnvelope::service(SERVICE, op, payload))
1753 .await?;
1754 reply_payload(reply)
1755}
1756
1757fn reply_payload(reply: DaemonReply) -> Result<Value> {
1760 if reply.ok {
1761 Ok(reply.payload)
1762 } else {
1763 bail!(
1764 "daemon returned an error: {}",
1765 reply.error.as_deref().unwrap_or("unknown error")
1766 )
1767 }
1768}
1769
1770fn render_windows(result: &Value) -> String {
1775 let windows = result
1776 .get("windows")
1777 .and_then(Value::as_array)
1778 .map(Vec::as_slice)
1779 .unwrap_or_default();
1780 if windows.is_empty() {
1781 return "No open windows.".to_string();
1782 }
1783 let mut out = format!(
1784 "{:<22} {:<24} {:<9} {:<40} {:>5}",
1785 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
1786 );
1787 for window in windows {
1788 let repo = sanitize(repo_name(window));
1789 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
1790 let sync = sync_summary(window);
1791 let folder_disp = folder_summary(window);
1792 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
1793 out.push_str(&format!(
1794 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
1795 ));
1796 }
1797 out
1798}
1799
1800fn render_tree(result: &Value) -> String {
1806 let repos = result
1807 .get("repos")
1808 .and_then(Value::as_array)
1809 .map(Vec::as_slice)
1810 .unwrap_or_default();
1811 if repos.is_empty() {
1812 return "No repositories open.".to_string();
1813 }
1814 let mut out = String::new();
1815 for (i, repo) in repos.iter().enumerate() {
1816 if i > 0 {
1819 out.push_str("\n\n");
1820 }
1821 out.push_str(&repo_header(repo));
1822 for worktree in repo
1823 .get("worktrees")
1824 .and_then(Value::as_array)
1825 .map(Vec::as_slice)
1826 .unwrap_or_default()
1827 {
1828 out.push('\n');
1829 out.push_str(&worktree_row(worktree));
1830 }
1831 }
1832 out
1833}
1834
1835fn repo_header(repo: &Value) -> String {
1838 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
1839 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
1840 match github_summary(repo) {
1841 Some(github) => format!("{name} ({github}) {root}"),
1842 None => format!("{name} {root}"),
1843 }
1844}
1845
1846fn github_summary(repo: &Value) -> Option<String> {
1849 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
1850 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
1851 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
1852}
1853
1854fn worktree_row(worktree: &Value) -> String {
1858 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
1859 '*'
1860 } else {
1861 ' '
1862 };
1863 let branch = sanitize(
1864 worktree
1865 .get("branch")
1866 .and_then(Value::as_str)
1867 .unwrap_or("-"),
1868 );
1869 let sync = sync_summary(worktree);
1870 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
1871 "open"
1872 } else {
1873 ""
1874 };
1875 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
1876 format!(" {marker} {branch:<24} {sync:<16} {open:<5} {path}")
1877}
1878
1879fn repo_name(window: &Value) -> &str {
1883 window
1884 .get("main_repo")
1885 .and_then(Value::as_str)
1886 .or_else(|| window.get("repo").and_then(Value::as_str))
1887 .unwrap_or("-")
1888}
1889
1890fn sync_summary(window: &Value) -> String {
1897 let ahead = window.get("ahead").and_then(Value::as_u64);
1898 let behind = window.get("behind").and_then(Value::as_u64);
1899 let base = match (ahead, behind) {
1900 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
1901 _ => "-".to_string(),
1902 };
1903 match window.get("main_behind").and_then(Value::as_u64) {
1904 Some(main_behind) => format!("{base} main-{main_behind}"),
1905 None => base,
1906 }
1907}
1908
1909fn folder_summary(window: &Value) -> String {
1912 let folders = window
1913 .get("folders")
1914 .and_then(Value::as_array)
1915 .map(Vec::as_slice)
1916 .unwrap_or_default();
1917 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
1918 let extra = folders.len().saturating_sub(1);
1919 if extra > 0 {
1920 format!("{first} (+{extra})")
1921 } else {
1922 first
1923 }
1924}
1925
1926fn sanitize(s: &str) -> String {
1930 sanitize_for_terminal(s)
1931}
1932
1933fn age_secs(ts: Option<&str>) -> i64 {
1935 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1936 .map_or(0, |t| {
1937 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1938 })
1939}
1940
1941#[cfg(test)]
1942#[allow(clippy::unwrap_used, clippy::expect_used)]
1943mod tests {
1944 use super::*;
1945 use serde_json::json;
1946
1947 #[derive(Parser)]
1949 struct Wrapper {
1950 #[command(subcommand)]
1951 cmd: WorktreesSubcommands,
1952 }
1953
1954 fn parse(args: &[&str]) -> WorktreesSubcommands {
1955 let mut full = vec!["omni-dev"];
1956 full.extend_from_slice(args);
1957 Wrapper::try_parse_from(full).unwrap().cmd
1958 }
1959
1960 #[test]
1961 fn list_parses_flags_and_defaults() {
1962 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
1964 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
1966 assert_eq!(cmd.output, TableOrJson::Table);
1967 assert!(!cmd.json);
1968 assert!(cmd.socket.is_none());
1969
1970 let cmd =
1971 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1972 assert_eq!(cmd.output, TableOrJson::Json);
1973 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1974 }
1975
1976 #[test]
1977 fn list_deprecated_json_flag_still_parses() {
1978 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
1980 assert!(cmd.json);
1981 assert_eq!(cmd.output, TableOrJson::Table);
1982 }
1983
1984 #[test]
1985 fn tree_parses_flags_and_defaults() {
1986 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
1988 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1989 assert_eq!(cmd.output, TableOrJson::Table);
1990 assert!(cmd.socket.is_none());
1991
1992 let cmd =
1993 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1994 assert_eq!(cmd.output, TableOrJson::Json);
1995 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1996 }
1997
1998 #[test]
1999 fn focus_parses_path_and_socket() {
2000 assert!(matches!(
2002 parse(&["focus", "/home/me/wt"]),
2003 WorktreesSubcommands::Focus(_)
2004 ));
2005 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
2007 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2008 assert!(cmd.socket.is_none());
2009
2010 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
2011 .unwrap();
2012 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2013
2014 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
2016 }
2017
2018 #[tokio::test]
2019 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
2020 let cmd = FocusCommand {
2023 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
2024 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
2025 };
2026 let err = cmd.execute().await.unwrap_err();
2027 assert!(
2028 err.to_string().contains("cannot resolve worktree path"),
2029 "{err}"
2030 );
2031 }
2032
2033 #[tokio::test]
2034 async fn focus_sends_the_open_op_for_an_existing_folder() {
2035 let (_dir, sock, server) =
2039 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
2040 let target = tempfile::tempdir().unwrap();
2041 let cmd = WorktreesCommand {
2042 command: WorktreesSubcommands::Focus(FocusCommand {
2043 path: target.path().to_path_buf(),
2044 socket: Some(sock),
2045 }),
2046 };
2047 cmd.execute(None).await.unwrap();
2048 server.await.unwrap();
2049 }
2050
2051 #[test]
2052 fn render_windows_handles_empty_replies() {
2053 assert_eq!(
2054 render_windows(&json!({ "windows": [] })),
2055 "No open windows."
2056 );
2057 assert_eq!(render_windows(&json!({})), "No open windows.");
2058 }
2059
2060 #[test]
2061 fn render_windows_renders_rows() {
2062 let result = json!({ "windows": [{
2063 "key": "w1",
2064 "repo": "omni-dev",
2065 "branch": "issue-1011",
2066 "ahead": 2,
2067 "behind": 1,
2068 "folders": ["/home/me/omni-dev", "/home/me/docs"],
2069 "last_seen": "2000-01-01T00:00:00Z",
2070 }]});
2071 let table = render_windows(&result);
2072 assert!(table.contains("omni-dev"), "{table}");
2073 assert!(table.contains("issue-1011"), "{table}");
2075 assert!(table.contains("+2 -1"), "{table}");
2076 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
2078 assert_eq!(table.lines().count(), 2, "{table}");
2080 }
2081
2082 #[test]
2083 fn render_windows_prefers_main_repo_over_companion_repo() {
2084 let result = json!({ "windows": [{
2088 "key": "w1",
2089 "repo": "issue-1250",
2090 "main_repo": "omni-dev",
2091 "branch": "issue-1250",
2092 "folders": ["/home/me/worktrees/issue-1250"],
2093 "last_seen": "2000-01-01T00:00:00Z",
2094 }]});
2095 let table = render_windows(&result);
2096 assert!(table.contains("omni-dev"), "{table}");
2097 let data_row = table.lines().nth(1).unwrap();
2100 assert!(data_row.starts_with("omni-dev"), "{data_row}");
2101 }
2102
2103 #[test]
2104 fn repo_name_falls_back_to_companion_repo_then_dash() {
2105 assert_eq!(
2106 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
2107 "omni-dev"
2108 );
2109 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
2110 assert_eq!(repo_name(&json!({})), "-");
2111 }
2112
2113 #[test]
2114 fn render_windows_strips_control_bytes() {
2115 let result = json!({ "windows": [{
2118 "key": "w1",
2119 "repo": "evil\x1b[31mrepo",
2120 "branch": "br\ranch\x07\u{9b}2J",
2121 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
2122 "last_seen": "2000-01-01T00:00:00Z",
2123 }]});
2124 let table = render_windows(&result);
2125 assert!(
2126 !table.contains(|c: char| c.is_control() && c != '\n'),
2127 "{table:?}"
2128 );
2129 assert!(table.contains("evil[31mrepo"), "{table:?}");
2131 assert!(table.contains("branch2J"), "{table:?}");
2132 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
2133 assert_eq!(table.lines().count(), 2, "{table:?}");
2135 }
2136
2137 #[test]
2138 fn sync_summary_formats_or_dashes() {
2139 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2140 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
2141 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
2143 assert_eq!(sync_summary(&json!({})), "-");
2144 }
2145
2146 #[test]
2147 fn sync_summary_appends_main_behind_when_present() {
2148 assert_eq!(
2151 sync_summary(&json!({ "ahead": 2, "behind": 1, "main_behind": 5 })),
2152 "+2 -1 main-5"
2153 );
2154 assert_eq!(sync_summary(&json!({ "main_behind": 7 })), "- main-7");
2155 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2158 }
2159
2160 #[test]
2161 fn folder_summary_strips_control_bytes() {
2162 assert_eq!(
2163 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
2164 "/a[2J/b"
2165 );
2166 }
2167
2168 #[test]
2169 fn folder_summary_counts_extra_folders() {
2170 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
2171 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
2172 assert_eq!(
2173 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
2174 "/a (+2)"
2175 );
2176 }
2177
2178 #[test]
2179 fn age_secs_handles_absent_and_unparseable_and_past() {
2180 assert_eq!(age_secs(None), 0);
2181 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
2182 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
2183 }
2184
2185 #[test]
2186 fn render_tree_handles_empty_replies() {
2187 assert_eq!(
2188 render_tree(&json!({ "repos": [] })),
2189 "No repositories open."
2190 );
2191 assert_eq!(render_tree(&json!({})), "No repositories open.");
2192 }
2193
2194 #[test]
2195 fn worktree_paths_collects_every_worktree_in_render_order() {
2196 let result = json!({ "repos": [
2197 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
2199 { "worktrees": [ { "path": "/c" } ] },
2200 ]});
2201 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
2202 assert!(worktree_paths(&json!({})).is_empty());
2204 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
2205 }
2206
2207 #[test]
2208 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
2209 let mut result = json!({ "repos": [{ "worktrees": [
2213 { "path": "/a", "branch": "main" },
2214 { "path": "/b", "branch": "feature" },
2215 ]}]});
2216 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
2217 merge_ahead_behind(&mut result, results.as_object().unwrap());
2218
2219 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2220 let a = &worktrees[0];
2221 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
2222 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2223 assert_eq!(sync_summary(a), "+2 -1");
2225 let b = &worktrees[1];
2226 assert!(b.get("ahead").is_none(), "{b:?}");
2227 assert!(b.get("behind").is_none(), "{b:?}");
2228 assert_eq!(sync_summary(b), "-");
2229 }
2230
2231 #[test]
2232 fn merge_ahead_behind_folds_main_behind_independently_of_ahead_behind() {
2233 let mut result = json!({ "repos": [{ "worktrees": [
2237 { "path": "/a", "branch": "feature" },
2238 { "path": "/b", "branch": "no-upstream" },
2239 { "path": "/c", "branch": "main" },
2240 ]}]});
2241 let results = json!({
2242 "/a": { "ahead": 1, "behind": 1, "main_behind": 3 },
2243 "/b": { "main_behind": 7 },
2244 "/c": { "ahead": 1, "behind": 1 },
2245 });
2246 merge_ahead_behind(&mut result, results.as_object().unwrap());
2247
2248 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2249 let a = &worktrees[0];
2250 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(1));
2251 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2252 assert_eq!(a.get("main_behind").and_then(Value::as_u64), Some(3));
2253
2254 let b = &worktrees[1];
2255 assert!(b.get("ahead").is_none(), "{b:?}");
2256 assert!(b.get("behind").is_none(), "{b:?}");
2257 assert_eq!(b.get("main_behind").and_then(Value::as_u64), Some(7));
2258
2259 let c = &worktrees[2];
2260 assert_eq!(c.get("ahead").and_then(Value::as_u64), Some(1));
2261 assert_eq!(c.get("behind").and_then(Value::as_u64), Some(1));
2262 assert!(c.get("main_behind").is_none(), "{c:?}");
2263 }
2264
2265 #[test]
2266 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
2267 let mut result = json!({ "repos": [{ "worktrees": [
2271 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
2275 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
2277
2278 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2279 assert_eq!(worktrees[0], json!("not-an-object"));
2281 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
2283 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
2285 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
2286 }
2287
2288 #[tokio::test]
2289 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
2290 let mut result = json!({ "repos": [] });
2293 let before = result.clone();
2294 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2295 assert_eq!(result, before);
2296 }
2297
2298 #[tokio::test]
2299 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
2300 let mut result =
2303 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2304 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2305 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2306 assert!(wt.get("ahead").is_none(), "{wt:?}");
2307 assert!(wt.get("behind").is_none(), "{wt:?}");
2308 }
2309
2310 fn fake_daemon_reply(
2315 reply: Value,
2316 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2317 use futures::{SinkExt, StreamExt};
2318 use tokio::net::UnixListener;
2319 use tokio_util::codec::{Framed, LinesCodec};
2320
2321 let dir = tempfile::tempdir_in("/tmp").unwrap();
2323 let sock = dir.path().join("d.sock");
2324 let listener = UnixListener::bind(&sock).unwrap();
2325 let server = tokio::spawn(async move {
2326 let (stream, _) = listener.accept().await.unwrap();
2327 let mut framed = Framed::new(stream, LinesCodec::new());
2328 let _req = framed.next().await.unwrap().unwrap();
2329 framed
2330 .send(serde_json::to_string(&reply).unwrap())
2331 .await
2332 .unwrap();
2333 });
2334 (dir, sock, server)
2335 }
2336
2337 fn fake_daemon_replies(
2341 replies: Vec<Value>,
2342 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2343 use futures::{SinkExt, StreamExt};
2344 use tokio::net::UnixListener;
2345 use tokio_util::codec::{Framed, LinesCodec};
2346
2347 let dir = tempfile::tempdir_in("/tmp").unwrap();
2348 let sock = dir.path().join("d.sock");
2349 let listener = UnixListener::bind(&sock).unwrap();
2350 let server = tokio::spawn(async move {
2351 for reply in replies {
2352 let (stream, _) = listener.accept().await.unwrap();
2353 let mut framed = Framed::new(stream, LinesCodec::new());
2354 let _req = framed.next().await.unwrap().unwrap();
2355 framed
2356 .send(serde_json::to_string(&reply).unwrap())
2357 .await
2358 .unwrap();
2359 }
2360 });
2361 (dir, sock, server)
2362 }
2363
2364 #[tokio::test]
2365 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
2366 let (_dir, sock, server) = fake_daemon_reply(
2367 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
2368 );
2369 let mut result =
2370 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2371 enrich_ahead_behind(&sock, &mut result).await;
2372 server.await.unwrap();
2373
2374 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2375 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
2376 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
2377 }
2378
2379 #[tokio::test]
2380 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
2381 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
2384 let mut result =
2385 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2386 enrich_ahead_behind(&sock, &mut result).await;
2387 server.await.unwrap();
2388
2389 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2390 assert!(wt.get("ahead").is_none(), "{wt:?}");
2391 assert!(wt.get("behind").is_none(), "{wt:?}");
2392 }
2393
2394 #[test]
2395 fn render_tree_groups_repos_and_worktrees() {
2396 let result = json!({ "repos": [{
2397 "main_repo": "omni-dev",
2398 "github": { "owner": "rust-works", "name": "omni-dev" },
2399 "root": "/home/me/omni-dev",
2400 "worktrees": [
2401 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
2402 "is_main": true, "open": true, "window_key": "w1" },
2403 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
2404 "is_main": false, "open": false },
2405 ],
2406 }]});
2407 let out = render_tree(&result);
2408 let header = out.lines().next().unwrap();
2410 assert!(header.contains("omni-dev"), "{out}");
2411 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
2412 assert!(header.contains("/home/me/omni-dev"), "{out}");
2413 assert!(
2415 out.lines()
2416 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
2417 "{out}"
2418 );
2419 let linked = out
2421 .lines()
2422 .find(|l| l.contains("issue-1300"))
2423 .unwrap_or_default();
2424 assert!(!linked.contains('*'), "{linked}");
2425 assert!(!linked.contains("open"), "{linked}");
2426 assert!(linked.contains("+1 -3"), "{linked}");
2427 assert_eq!(out.lines().count(), 3, "{out}");
2429 }
2430
2431 #[test]
2432 fn render_tree_separates_multiple_repos_with_blank_line() {
2433 let result = json!({ "repos": [
2434 {
2435 "main_repo": "alpha",
2436 "root": "/r/alpha",
2437 "worktrees": [
2438 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
2439 ],
2440 },
2441 {
2442 "main_repo": "beta",
2443 "root": "/r/beta",
2444 "worktrees": [
2445 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
2446 ],
2447 },
2448 ]});
2449 let out = render_tree(&result);
2450 assert!(
2452 out.contains("\n\nbeta"),
2453 "repos not blank-separated: {out:?}"
2454 );
2455 let alpha = out.find("alpha").unwrap();
2456 let beta = out.find("beta").unwrap();
2457 assert!(alpha < beta, "repo order not preserved: {out}");
2458 assert_eq!(out.lines().count(), 5, "{out:?}");
2459 }
2460
2461 #[test]
2462 fn render_tree_omits_github_for_non_github_repo() {
2463 let result = json!({ "repos": [{
2464 "main_repo": "internal",
2465 "root": "/srv/internal",
2466 "worktrees": [
2467 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
2468 ],
2469 }]});
2470 let out = render_tree(&result);
2471 assert!(!out.contains("github:"), "{out}");
2472 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
2473 }
2474
2475 #[test]
2476 fn render_tree_strips_control_bytes() {
2477 let result = json!({ "repos": [{
2480 "main_repo": "evil\x1b[31mrepo",
2481 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
2482 "root": "/tmp/r\x1b]0;x\x07oot",
2483 "worktrees": [
2484 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
2485 ],
2486 }]});
2487 let out = render_tree(&result);
2488 assert!(
2489 !out.contains(|c: char| c.is_control() && c != '\n'),
2490 "{out:?}"
2491 );
2492 assert_eq!(out.lines().count(), 2, "{out:?}");
2494 }
2495
2496 #[test]
2497 fn github_summary_needs_both_owner_and_name() {
2498 assert_eq!(
2499 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
2500 Some("github: o/n")
2501 );
2502 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
2503 assert_eq!(github_summary(&json!({})), None);
2504 }
2505
2506 #[test]
2507 fn reply_payload_unwraps_ok_and_maps_errors() {
2508 assert_eq!(
2510 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2511 json!({ "a": 1 })
2512 );
2513 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2515 assert!(err.to_string().contains("boom"), "{err}");
2516 let err = reply_payload(DaemonReply {
2518 ok: false,
2519 payload: Value::Null,
2520 error: None,
2521 })
2522 .unwrap_err();
2523 assert!(err.to_string().contains("unknown error"), "{err}");
2524 }
2525
2526 #[test]
2529 fn new_subcommands_route_and_require_their_args() {
2530 assert!(matches!(
2531 parse(&["close", "/home/me/wt"]),
2532 WorktreesSubcommands::Close(_)
2533 ));
2534 assert!(matches!(
2535 parse(&["show-closed"]),
2536 WorktreesSubcommands::ShowClosed(_)
2537 ));
2538 assert!(matches!(
2539 parse(&["register", "--key", "w1"]),
2540 WorktreesSubcommands::Register(_)
2541 ));
2542 assert!(matches!(
2543 parse(&["heartbeat", "--key", "w1"]),
2544 WorktreesSubcommands::Heartbeat(_)
2545 ));
2546 assert!(matches!(
2547 parse(&["unregister", "--key", "w1"]),
2548 WorktreesSubcommands::Unregister(_)
2549 ));
2550
2551 assert!(CloseCommand::try_parse_from(["close"]).is_err());
2553 assert!(RegisterCommand::try_parse_from(["register"]).is_err());
2554 assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
2555 assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
2556 }
2557
2558 #[test]
2559 fn close_parses_flags() {
2560 let cmd = CloseCommand::try_parse_from([
2561 "close",
2562 "/home/me/wt",
2563 "--window-only",
2564 "--dry-run",
2565 "-y",
2566 "--socket",
2567 "/tmp/d.sock",
2568 ])
2569 .unwrap();
2570 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2571 assert!(cmd.window_only && cmd.dry_run && cmd.yes);
2572 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2573
2574 let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
2576 assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
2577 }
2578
2579 #[test]
2580 fn tree_follow_flag_parses() {
2581 let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
2582 assert!(cmd.follow);
2583 let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
2584 assert!(cmd.follow);
2585 assert_eq!(cmd.output, TableOrJson::Json);
2586 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2587 assert!(!cmd.follow);
2588 }
2589
2590 #[test]
2591 fn show_closed_parses_optional_bool() {
2592 assert!(ShowClosedCommand::try_parse_from(["show-closed"])
2593 .unwrap()
2594 .value
2595 .is_none());
2596 assert_eq!(
2597 ShowClosedCommand::try_parse_from(["show-closed", "false"])
2598 .unwrap()
2599 .value,
2600 Some(false)
2601 );
2602 assert_eq!(
2603 ShowClosedCommand::try_parse_from(["show-closed", "true"])
2604 .unwrap()
2605 .value,
2606 Some(true)
2607 );
2608 assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
2610 }
2611
2612 #[test]
2613 fn register_collects_repeated_folders() {
2614 let cmd = RegisterCommand::try_parse_from([
2615 "register",
2616 "--key",
2617 "w1",
2618 "--folder",
2619 "/a",
2620 "--folder",
2621 "/b",
2622 "--repo-name",
2623 "r",
2624 "--pid",
2625 "42",
2626 ])
2627 .unwrap();
2628 assert_eq!(cmd.key, "w1");
2629 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
2630 assert_eq!(cmd.repo_name.as_deref(), Some("r"));
2631 assert_eq!(cmd.pid, Some(42));
2632 }
2633
2634 #[test]
2635 fn answer_is_yes_accepts_only_affirmatives() {
2636 for yes in ["y", "Y", "yes", "YES", " yes \n"] {
2637 assert!(answer_is_yes(yes), "{yes:?}");
2638 }
2639 for no in ["", "n", "no", "nope", "true", "\n"] {
2640 assert!(!answer_is_yes(no), "{no:?}");
2641 }
2642 }
2643
2644 #[test]
2645 fn confirm_prompt_mentions_risks_only_when_present() {
2646 assert!(confirm_prompt(true).contains("risks"));
2650 assert!(!confirm_prompt(false).contains("risks"));
2651 assert!(confirm_prompt(true).contains("[y/N]"));
2652 assert!(confirm_prompt(false).contains("[y/N]"));
2653 }
2654
2655 #[test]
2656 fn read_line_from_maps_input_and_eof() {
2657 use std::io::Cursor;
2658 assert_eq!(
2662 read_line_from(&mut Cursor::new("y\n")).as_deref(),
2663 Some("y\n")
2664 );
2665 assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
2666 assert_eq!(
2667 read_line_from(&mut Cursor::new("no-newline")).as_deref(),
2668 Some("no-newline")
2669 );
2670 }
2671
2672 #[test]
2673 fn render_safety_report_renders_fields_and_notes() {
2674 let report = json!({
2675 "removable": true,
2676 "is_main": false,
2677 "open": true,
2678 "window_key": "w1",
2679 "window_folder_count": 2,
2680 "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
2681 "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
2682 });
2683 let out = render_safety_report(Path::new("/home/me/wt"), &report);
2684 assert!(out.contains("/home/me/wt"), "{out}");
2685 assert!(out.contains("removable: true"), "{out}");
2686 assert!(
2687 out.contains("open in a window: yes (key w1, 2 folder(s))"),
2688 "{out}"
2689 );
2690 assert!(out.contains("[dirty] uncommitted changes"), "{out}");
2691 assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
2692 }
2693
2694 #[test]
2695 fn render_safety_report_handles_no_window_and_no_notes() {
2696 let report = json!({ "removable": false, "is_main": true, "open": false });
2697 let out = render_safety_report(Path::new("/r"), &report);
2698 assert!(out.contains("removable: false"), "{out}");
2699 assert!(out.contains("main working tree: true"), "{out}");
2700 assert!(out.contains("open in a window: no"), "{out}");
2701 assert!(!out.contains("risks:"), "{out}");
2703 assert!(!out.contains("info:"), "{out}");
2704 }
2705
2706 #[test]
2707 fn render_safety_report_strips_control_bytes() {
2708 let report = json!({
2711 "removable": true, "is_main": false, "open": true,
2712 "window_key": "w\x1b[31m1", "window_folder_count": 1,
2713 "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
2714 "info": [],
2715 });
2716 let out = render_safety_report(Path::new("/r"), &report);
2717 assert!(
2718 !out.contains(|c: char| c.is_control() && c != '\n'),
2719 "{out:?}"
2720 );
2721 }
2722
2723 fn fake_daemon_seq(
2729 replies: Vec<Value>,
2730 ) -> (
2731 tempfile::TempDir,
2732 PathBuf,
2733 tokio::task::JoinHandle<Vec<Value>>,
2734 ) {
2735 use futures::{SinkExt, StreamExt};
2736 use tokio::net::UnixListener;
2737 use tokio_util::codec::{Framed, LinesCodec};
2738
2739 let dir = tempfile::tempdir_in("/tmp").unwrap();
2740 let sock = dir.path().join("d.sock");
2741 let listener = UnixListener::bind(&sock).unwrap();
2742 let server = tokio::spawn(async move {
2743 let mut requests = Vec::new();
2744 for reply in replies {
2745 let (stream, _) = listener.accept().await.unwrap();
2746 let mut framed = Framed::new(stream, LinesCodec::new());
2747 let req = framed.next().await.unwrap().unwrap();
2748 requests.push(serde_json::from_str::<Value>(&req).unwrap());
2749 framed
2750 .send(serde_json::to_string(&reply).unwrap())
2751 .await
2752 .unwrap();
2753 }
2754 requests
2755 });
2756 (dir, sock, server)
2757 }
2758
2759 #[tokio::test]
2760 async fn close_window_only_sends_remove_false() {
2761 let (_dir, sock, server) =
2762 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
2763 let target = tempfile::tempdir().unwrap();
2764 CloseCommand {
2765 path: target.path().to_path_buf(),
2766 window_only: true,
2767 dry_run: false,
2768 yes: false,
2769 socket: Some(sock),
2770 }
2771 .execute()
2772 .await
2773 .unwrap();
2774 let reqs = server.await.unwrap();
2775 assert_eq!(reqs.len(), 1);
2778 assert_eq!(reqs[0]["op"], "close");
2779 assert_eq!(reqs[0]["payload"]["remove"], json!(false));
2780 assert!(
2781 reqs[0]["payload"].get("confirmed").is_none(),
2782 "{:?}",
2783 reqs[0]
2784 );
2785 let want = std::fs::canonicalize(target.path()).unwrap();
2787 assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
2788 }
2789
2790 #[tokio::test]
2791 async fn close_window_only_dry_run_never_contacts_the_daemon() {
2792 let target = tempfile::tempdir().unwrap();
2795 CloseCommand {
2796 path: target.path().to_path_buf(),
2797 window_only: true,
2798 dry_run: true,
2799 yes: false,
2800 socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
2801 }
2802 .execute()
2803 .await
2804 .unwrap();
2805 }
2806
2807 #[tokio::test]
2808 async fn close_dry_run_only_runs_phase_one() {
2809 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2811 "ok": true,
2812 "payload": { "removable": true, "is_main": false, "open": false,
2813 "window_folder_count": 0, "risks": [], "info": [] }
2814 })]);
2815 let target = tempfile::tempdir().unwrap();
2816 CloseCommand {
2817 path: target.path().to_path_buf(),
2818 window_only: false,
2819 dry_run: true,
2820 yes: false,
2821 socket: Some(sock),
2822 }
2823 .execute()
2824 .await
2825 .unwrap();
2826 let reqs = server.await.unwrap();
2827 assert_eq!(reqs.len(), 1);
2829 assert_eq!(reqs[0]["op"], "close");
2830 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2831 assert!(
2832 reqs[0]["payload"].get("confirmed").is_none(),
2833 "{:?}",
2834 reqs[0]
2835 );
2836 }
2837
2838 #[tokio::test]
2839 async fn close_yes_executes_phase_two() {
2840 let (_dir, sock, server) = fake_daemon_seq(vec![
2842 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2843 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2844 json!({ "ok": true, "payload": { "removed": true } }),
2845 ]);
2846 let target = tempfile::tempdir().unwrap();
2847 CloseCommand {
2848 path: target.path().to_path_buf(),
2849 window_only: false,
2850 dry_run: false,
2851 yes: true,
2852 socket: Some(sock),
2853 }
2854 .execute()
2855 .await
2856 .unwrap();
2857 let reqs = server.await.unwrap();
2858 assert_eq!(reqs.len(), 2);
2860 assert_eq!(reqs[0]["op"], "close");
2861 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2862 assert!(
2863 reqs[0]["payload"].get("confirmed").is_none(),
2864 "{:?}",
2865 reqs[0]
2866 );
2867 assert_eq!(reqs[1]["op"], "close");
2868 assert_eq!(reqs[1]["payload"]["remove"], json!(true));
2869 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2870 assert!(
2872 reqs[1]["payload"].get("requester_key").is_none(),
2873 "{:?}",
2874 reqs[1]
2875 );
2876 }
2877
2878 #[tokio::test]
2879 async fn close_refuses_a_non_removable_target() {
2880 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2883 "ok": true,
2884 "payload": { "removable": false, "is_main": true, "open": false,
2885 "window_folder_count": 0, "risks": [], "info": [] }
2886 })]);
2887 let target = tempfile::tempdir().unwrap();
2888 let err = CloseCommand {
2889 path: target.path().to_path_buf(),
2890 window_only: false,
2891 dry_run: false,
2892 yes: true,
2893 socket: Some(sock),
2894 }
2895 .execute()
2896 .await
2897 .unwrap_err();
2898 assert!(
2899 err.to_string().contains("not a removable worktree"),
2900 "{err}"
2901 );
2902 assert_eq!(server.await.unwrap().len(), 1);
2904 }
2905
2906 #[tokio::test]
2907 async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
2908 let err = CloseCommand {
2909 path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
2910 window_only: false,
2911 dry_run: false,
2912 yes: true,
2913 socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
2914 }
2915 .execute()
2916 .await
2917 .unwrap_err();
2918 assert!(
2919 err.to_string().contains("cannot resolve worktree path"),
2920 "{err}"
2921 );
2922 }
2923
2924 #[tokio::test]
2925 async fn show_closed_sets_and_reads() {
2926 let (_dir, sock, server) =
2928 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2929 ShowClosedCommand {
2930 value: Some(false),
2931 socket: Some(sock),
2932 }
2933 .execute()
2934 .await
2935 .unwrap();
2936 let reqs = server.await.unwrap();
2937 assert_eq!(reqs[0]["op"], "set-show-closed");
2938 assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
2939
2940 let (_dir, sock, server) = fake_daemon_seq(vec![
2942 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2943 ]);
2944 ShowClosedCommand {
2945 value: None,
2946 socket: Some(sock),
2947 }
2948 .execute()
2949 .await
2950 .unwrap();
2951 assert_eq!(server.await.unwrap()[0]["op"], "tree");
2953 }
2954
2955 #[tokio::test]
2956 async fn register_heartbeat_unregister_send_their_ops() {
2957 let (_dir, sock, server) =
2958 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2959 RegisterCommand {
2960 key: "w1".to_string(),
2961 folders: vec![PathBuf::from("/a")],
2962 repo_name: Some("r".to_string()),
2963 title: None,
2964 pid: Some(7),
2965 socket: Some(sock),
2966 }
2967 .execute()
2968 .await
2969 .unwrap();
2970 let reqs = server.await.unwrap();
2971 assert_eq!(reqs[0]["op"], "register");
2973 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2974 assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
2975 assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
2976 assert_eq!(reqs[0]["payload"]["pid"], json!(7));
2977
2978 let (_dir, sock, server) = fake_daemon_seq(vec![
2979 json!({ "ok": true, "payload": { "known": true, "close": true } }),
2980 ]);
2981 HeartbeatCommand {
2982 key: "w1".to_string(),
2983 socket: Some(sock),
2984 }
2985 .execute()
2986 .await
2987 .unwrap();
2988 let reqs = server.await.unwrap();
2989 assert_eq!(reqs[0]["op"], "heartbeat");
2990 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2991
2992 let (_dir, sock, server) =
2993 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
2994 UnregisterCommand {
2995 key: "w1".to_string(),
2996 socket: Some(sock),
2997 }
2998 .execute()
2999 .await
3000 .unwrap();
3001 let reqs = server.await.unwrap();
3002 assert_eq!(reqs[0]["op"], "unregister");
3003 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
3004 }
3005
3006 #[tokio::test]
3007 async fn tree_follow_renders_each_pushed_frame() {
3008 use crate::daemon::testutil::fake_daemon_stream;
3009
3010 let (_dir, sock, server) = fake_daemon_stream(vec![
3012 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3013 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
3014 ]);
3015 follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
3016 server.await.unwrap();
3017
3018 let (_dir, sock, server) = fake_daemon_stream(vec![
3021 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3022 ]);
3023 follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
3024 server.await.unwrap();
3025
3026 let (_dir, sock, server) = fake_daemon_stream(vec![
3029 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3030 ]);
3031 TreeCommand {
3032 socket: Some(sock),
3033 output: TableOrJson::Json,
3034 follow: true,
3035 }
3036 .execute()
3037 .await
3038 .unwrap();
3039 server.await.unwrap();
3040 }
3041
3042 #[tokio::test]
3043 async fn worktrees_command_routes_each_new_subcommand() {
3044 let target = tempfile::tempdir().unwrap();
3048 WorktreesCommand {
3050 command: WorktreesSubcommands::Close(CloseCommand {
3051 path: target.path().to_path_buf(),
3052 window_only: true,
3053 dry_run: true,
3054 yes: false,
3055 socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
3056 }),
3057 }
3058 .execute(None)
3059 .await
3060 .unwrap();
3061
3062 WorktreesCommand {
3065 command: WorktreesSubcommands::Rebase(RebaseCommand {
3066 paths: vec![target.path().to_path_buf()],
3067 dry_run: true,
3068 ..rebase_cmd()
3069 }),
3070 }
3071 .execute(None)
3072 .await
3073 .unwrap();
3074
3075 let (_d, sock, server) =
3077 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3078 WorktreesCommand {
3079 command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
3080 value: Some(true),
3081 socket: Some(sock),
3082 }),
3083 }
3084 .execute(None)
3085 .await
3086 .unwrap();
3087 server.await.unwrap();
3088
3089 let (_d, sock, server) = fake_daemon_seq(vec![json!({
3091 "ok": true,
3092 "payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
3093 })]);
3094 WorktreesCommand {
3095 command: WorktreesSubcommands::Reposition(RepositionCommand {
3096 paths: Vec::new(),
3097 reference: None,
3098 dry_run: false,
3099 undo: true,
3100 output: TableOrJson::Table,
3101 socket: Some(sock),
3102 }),
3103 }
3104 .execute(None)
3105 .await
3106 .unwrap();
3107 server.await.unwrap();
3108
3109 let (_d, sock, server) = fake_daemon_seq(vec![
3112 json!({ "ok": true, "payload": { "windows": [] } }),
3113 json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
3114 ]);
3115 WorktreesCommand {
3116 command: WorktreesSubcommands::Reload(ReloadCommand {
3117 paths: Vec::new(),
3118 output: TableOrJson::Table,
3119 socket: Some(sock),
3120 }),
3121 }
3122 .execute(None)
3123 .await
3124 .unwrap();
3125 server.await.unwrap();
3126
3127 let (_d, sock, server) =
3129 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3130 WorktreesCommand {
3131 command: WorktreesSubcommands::Register(RegisterCommand {
3132 key: "w1".to_string(),
3133 folders: vec![],
3134 repo_name: None,
3135 title: None,
3136 pid: None,
3137 socket: Some(sock),
3138 }),
3139 }
3140 .execute(None)
3141 .await
3142 .unwrap();
3143 server.await.unwrap();
3144
3145 let (_d, sock, server) =
3147 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
3148 WorktreesCommand {
3149 command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
3150 key: "w1".to_string(),
3151 socket: Some(sock),
3152 }),
3153 }
3154 .execute(None)
3155 .await
3156 .unwrap();
3157 server.await.unwrap();
3158
3159 let (_d, sock, server) =
3161 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
3162 WorktreesCommand {
3163 command: WorktreesSubcommands::Unregister(UnregisterCommand {
3164 key: "w1".to_string(),
3165 socket: Some(sock),
3166 }),
3167 }
3168 .execute(None)
3169 .await
3170 .unwrap();
3171 server.await.unwrap();
3172 }
3173
3174 #[tokio::test]
3175 async fn close_aborts_when_confirmation_is_declined() {
3176 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
3180 "ok": true,
3181 "payload": { "removable": true, "is_main": false, "open": false,
3182 "window_folder_count": 0, "risks": [], "info": [] }
3183 })]);
3184 let target = tempfile::tempdir().unwrap();
3185 CloseCommand {
3186 path: target.path().to_path_buf(),
3187 window_only: false,
3188 dry_run: false,
3189 yes: false,
3190 socket: Some(sock),
3191 }
3192 .execute_with(|_has_risks| async { false })
3193 .await
3194 .unwrap();
3195 assert_eq!(server.await.unwrap().len(), 1);
3196 }
3197
3198 #[tokio::test]
3199 async fn close_deletes_when_confirmation_is_accepted() {
3200 let (_dir, sock, server) = fake_daemon_seq(vec![
3203 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
3204 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
3205 json!({ "ok": true, "payload": { "removed": true } }),
3206 ]);
3207 let target = tempfile::tempdir().unwrap();
3208 CloseCommand {
3209 path: target.path().to_path_buf(),
3210 window_only: false,
3211 dry_run: false,
3212 yes: false,
3213 socket: Some(sock),
3214 }
3215 .execute_with(|_has_risks| async { true })
3216 .await
3217 .unwrap();
3218 let reqs = server.await.unwrap();
3219 assert_eq!(reqs.len(), 2);
3220 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
3221 }
3222
3223 #[tokio::test]
3224 async fn confirm_removal_with_decides_from_the_answer() {
3225 assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
3228 assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
3229 assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
3230 assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
3231 assert!(!confirm_removal_with(false, async { None }).await);
3232 }
3233
3234 fn rebase_cmd() -> RebaseCommand {
3238 RebaseCommand {
3239 paths: Vec::new(),
3240 all: false,
3241 onto: None,
3242 autostash: false,
3243 dry_run: false,
3244 keep_conflicts: false,
3245 yes: false,
3246 output: TableOrJson::Table,
3247 }
3248 }
3249
3250 #[test]
3251 fn rebase_parses_paths_and_flags() {
3252 let cmd = RebaseCommand::try_parse_from([
3253 "rebase",
3254 "/wt/a",
3255 "/wt/b",
3256 "--onto",
3257 "origin/release",
3258 "--autostash",
3259 "--dry-run",
3260 "--keep-conflicts",
3261 "-y",
3262 "-o",
3263 "json",
3264 ])
3265 .unwrap();
3266 assert_eq!(
3267 cmd.paths,
3268 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3269 );
3270 assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
3271 assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
3272 assert!(matches!(cmd.output, TableOrJson::Json));
3273 }
3274
3275 #[test]
3276 fn rebase_defaults_are_conservative() {
3277 let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
3278 assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
3279 assert!(!cmd.keep_conflicts);
3282 assert_eq!(cmd.onto, None);
3283 assert!(matches!(cmd.output, TableOrJson::Table));
3284 }
3285
3286 #[test]
3287 fn rebase_requires_a_target() {
3288 let err = rebase_cmd().selection(None).unwrap_err().to_string();
3291 assert!(err.contains("--all"), "expected a usage hint, got: {err}");
3292 }
3293
3294 #[test]
3295 fn rebase_rejects_paths_together_with_all() {
3296 let cmd = RebaseCommand {
3297 paths: vec![PathBuf::from("/wt/a")],
3298 all: true,
3299 ..rebase_cmd()
3300 };
3301 let err = cmd.selection(None).unwrap_err().to_string();
3302 assert!(err.contains("not both"), "got: {err}");
3303 }
3304
3305 #[test]
3306 fn rebase_selection_maps_paths_and_all() {
3307 let cmd = RebaseCommand {
3308 paths: vec![PathBuf::from("/wt/a")],
3309 ..rebase_cmd()
3310 };
3311 assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
3312 let all = RebaseCommand {
3313 all: true,
3314 ..rebase_cmd()
3315 };
3316 assert!(matches!(
3317 all.selection(None).unwrap(),
3318 Selection::All { .. }
3319 ));
3320 }
3321
3322 #[test]
3323 fn rebase_prompt_agrees_in_number() {
3324 assert!(rebase_prompt(1).contains("1 worktree ("));
3325 assert!(rebase_prompt(3).contains("3 worktrees ("));
3326 assert!(rebase_prompt(2).contains("rewrites branch history"));
3328 }
3329
3330 #[tokio::test]
3331 async fn confirm_rebase_with_decides_from_the_answer() {
3332 assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
3333 assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
3334 assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
3335 assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
3336 assert!(!confirm_rebase_with(1, async { None }).await);
3337 }
3338
3339 #[test]
3340 fn fetch_line_reports_each_repos_single_fetch() {
3341 let ok = FetchOutcome {
3342 repo_root: PathBuf::from("/repo"),
3343 onto: "origin/main".to_string(),
3344 fetched: true,
3345 ok: true,
3346 detail: None,
3347 };
3348 assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
3349
3350 let failed = FetchOutcome {
3351 detail: Some("host unreachable".to_string()),
3352 ok: false,
3353 ..ok.clone()
3354 };
3355 assert!(fetch_line(&failed).contains("FAILED"));
3356
3357 let local = FetchOutcome {
3358 fetched: false,
3359 onto: "develop".to_string(),
3360 ..ok
3361 };
3362 assert!(fetch_line(&local).contains("nothing fetched"));
3363 }
3364
3365 #[test]
3366 fn outcome_rows_render_each_status() {
3367 let row = |result| {
3368 outcome_row(&WorktreeOutcome {
3369 path: PathBuf::from("/wt"),
3370 branch: Some("feature".to_string()),
3371 onto: "origin/main".to_string(),
3372 result,
3373 })
3374 };
3375 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
3376 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
3377 assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
3378 assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
3379 assert!(row(RebaseResult::Skipped {
3380 reason: SkipReason::Dirty
3381 })
3382 .contains("--autostash"));
3383 assert!(row(RebaseResult::Conflict {
3384 detail: "CONFLICT (content)".to_string(),
3385 left_in_place: false,
3386 })
3387 .contains("conflict"));
3388 let kept = row(RebaseResult::Conflict {
3391 detail: "CONFLICT (content)".to_string(),
3392 left_in_place: true,
3393 });
3394 assert!(kept.contains("conflict"), "{kept}");
3395 assert!(kept.contains("git rebase --continue"), "{kept}");
3396 assert!(row(RebaseResult::FetchFailed {
3397 detail: "host unreachable".to_string()
3398 })
3399 .contains("fetch-failed"));
3400 assert!(row(RebaseResult::Skipped {
3402 reason: SkipReason::DetachedHead
3403 })
3404 .contains("detached HEAD"));
3405 assert!(row(RebaseResult::Skipped {
3406 reason: SkipReason::OperationInProgress
3407 })
3408 .contains("in progress"));
3409 assert!(row(RebaseResult::Skipped {
3410 reason: SkipReason::NotAWorktree
3411 })
3412 .contains("not a git worktree"));
3413 assert!(row(RebaseResult::Skipped {
3414 reason: SkipReason::NoOntoRef
3415 })
3416 .contains("resolve the target ref"));
3417 }
3418
3419 #[test]
3420 fn print_emits_both_json_and_table_without_error() {
3421 let fetches = vec![FetchOutcome {
3422 repo_root: PathBuf::from("/r"),
3423 onto: "origin/main".to_string(),
3424 fetched: true,
3425 ok: true,
3426 detail: None,
3427 }];
3428 let outcomes = vec![WorktreeOutcome {
3429 path: PathBuf::from("/wt"),
3430 branch: Some("feature".to_string()),
3431 onto: "origin/main".to_string(),
3432 result: RebaseResult::UpToDate,
3433 }];
3434 let json_cmd = RebaseCommand {
3436 dry_run: true,
3437 output: TableOrJson::Json,
3438 ..rebase_cmd()
3439 };
3440 json_cmd.print(true, &fetches, &outcomes).unwrap();
3441 rebase_cmd().print(false, &fetches, &outcomes).unwrap();
3442 }
3443
3444 #[test]
3445 fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
3446 assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
3447 let long = "x".repeat(200);
3448 let out = brief(&long);
3449 assert_eq!(out.chars().count(), 100);
3450 assert!(out.ends_with("..."));
3451 assert_eq!(brief("a\u{7}b"), "ab");
3453 }
3454
3455 #[test]
3456 fn empty_report_renders_placeholders() {
3457 assert_eq!(render_fetches(&[]), "No repository selected.");
3458 assert_eq!(render_outcomes(&[]), "No worktrees selected.");
3459 }
3460
3461 #[allow(clippy::await_holding_lock)]
3467 #[tokio::test]
3468 async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
3469 let _guard = crate::git::worktree_batch::test_serial_lock();
3474 let Some(scenario) = BehindScenario::build() else {
3475 return; };
3477 let before = scenario.worktree_head();
3478 RebaseCommand {
3479 paths: vec![scenario.worktree.clone()],
3480 ..rebase_cmd()
3481 }
3482 .execute_with(None, |pending| async move {
3483 assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
3484 false
3485 })
3486 .await
3487 .unwrap();
3488 assert_eq!(
3489 scenario.worktree_head(),
3490 before,
3491 "declining the confirm must not rebase"
3492 );
3493 }
3494
3495 #[allow(clippy::await_holding_lock)]
3498 #[tokio::test]
3499 async fn rebase_confirmed_rebases_the_behind_worktree() {
3500 let _guard = crate::git::worktree_batch::test_serial_lock();
3503 let Some(scenario) = BehindScenario::build() else {
3504 return; };
3506 let before = scenario.worktree_head();
3507 RebaseCommand {
3508 paths: vec![scenario.worktree.clone()],
3509 ..rebase_cmd()
3510 }
3511 .execute_with(None, |pending| async move {
3512 assert_eq!(pending, 1);
3513 true
3514 })
3515 .await
3516 .unwrap();
3517 assert_ne!(
3518 scenario.worktree_head(),
3519 before,
3520 "confirming the prompt must rebase the worktree"
3521 );
3522 }
3523
3524 struct BehindScenario {
3528 _root: tempfile::TempDir,
3529 worktree: PathBuf,
3530 }
3531
3532 impl BehindScenario {
3533 fn build() -> Option<Self> {
3534 use git2::Repository;
3535 let root = tempfile::tempdir().ok()?;
3536 let origin = root.path().join("origin.git");
3537 let local = root.path().join("local");
3538 let worktree = root.path().join("feature");
3539 std::fs::create_dir_all(&origin).ok()?;
3540 std::fs::create_dir_all(&local).ok()?;
3541 run(&origin, &["init", "--bare", "-b", "main"])?;
3542 run(&local, &["init", "-b", "main"])?;
3543 Self::identity(&local)?;
3544 std::fs::write(local.join("f.txt"), "one\n").ok()?;
3545 run(&local, &["add", "f.txt"])?;
3546 run(&local, &["commit", "-m", "one"])?;
3547 run(&local, &["remote", "add", "origin", origin.to_str()?])?;
3548 run(&local, &["push", "-u", "origin", "main"])?;
3549 run(
3550 &local,
3551 &[
3552 "worktree",
3553 "add",
3554 "-b",
3555 "feature",
3556 worktree.to_str()?,
3557 "main",
3558 ],
3559 )?;
3560 let repo = Repository::open_bare(&origin).ok()?;
3563 let parent = repo
3564 .find_commit(repo.refname_to_id("refs/heads/main").ok()?)
3565 .ok()?;
3566 let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
3567 let blob = repo.blob(b"two\n").ok()?;
3568 builder.insert("f.txt", blob, 0o100_644).ok()?;
3569 let tree = repo.find_tree(builder.write().ok()?).ok()?;
3570 let sig = git2::Signature::now("Other", "other@example.com").ok()?;
3571 repo.commit(
3572 Some("refs/heads/main"),
3573 &sig,
3574 &sig,
3575 "two",
3576 &tree,
3577 &[&parent],
3578 )
3579 .ok()?;
3580 Some(Self {
3581 _root: root,
3582 worktree,
3583 })
3584 }
3585
3586 fn identity(dir: &Path) -> Option<()> {
3589 run(dir, &["config", "user.name", "Test"])?;
3590 run(dir, &["config", "user.email", "test@example.com"])?;
3591 run(dir, &["config", "commit.gpgsign", "false"])
3592 }
3593
3594 fn worktree_head(&self) -> String {
3595 let out = std::process::Command::new("git")
3596 .current_dir(&self.worktree)
3597 .args(["rev-parse", "HEAD"])
3598 .output();
3599 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3600 .unwrap_or_default()
3601 }
3602 }
3603
3604 fn run(dir: &Path, args: &[&str]) -> Option<()> {
3606 let output = std::process::Command::new("git")
3607 .current_dir(dir)
3608 .args(args)
3609 .output()
3610 .ok()?;
3611 output.status.success().then_some(())
3612 }
3613
3614 #[test]
3617 fn merge_queue_parses_paths_and_flags() {
3618 assert!(matches!(
3620 parse(&["merge-queue", "/a"]),
3621 WorktreesSubcommands::MergeQueue(_)
3622 ));
3623 let cmd =
3625 MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
3626 assert_eq!(cmd.paths.len(), 2);
3627 assert!(cmd.check);
3628 assert!(!cmd.yes);
3629 assert!(cmd.socket.is_none());
3630 let cmd = MergeQueueCommand::try_parse_from([
3632 "merge-queue",
3633 "/a",
3634 "-y",
3635 "--socket",
3636 "/tmp/d.sock",
3637 ])
3638 .unwrap();
3639 assert!(cmd.yes);
3640 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
3641 assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
3643 }
3644
3645 #[test]
3646 fn render_eligibility_report_lists_eligible_and_skipped() {
3647 let report = json!({
3648 "eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
3649 "skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
3650 });
3651 let out = render_eligibility_report(&report);
3652 assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
3653 assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
3654 assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
3655 }
3656
3657 #[test]
3658 fn render_enqueue_result_marks_already_queued_and_failures() {
3659 let result = json!({
3660 "queued": [
3661 { "number": 10, "path": "/a" },
3662 { "number": 11, "path": "/b", "already_queued": true },
3663 ],
3664 "failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
3665 "skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
3666 });
3667 let out = render_enqueue_result(&result);
3668 assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
3669 assert!(out.contains("queued: PR #10"), "{out}");
3670 assert!(out.contains("PR #11 (already queued)"), "{out}");
3671 assert!(
3672 out.contains("failed: PR #12 — merge queue not enabled"),
3673 "{out}"
3674 );
3675 }
3676
3677 #[test]
3678 fn render_eligibility_report_strips_control_bytes() {
3679 let report = json!({
3682 "eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
3683 "skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
3684 });
3685 let out = render_eligibility_report(&report);
3686 assert!(
3687 !out.contains(|c: char| c.is_control() && c != '\n'),
3688 "{out:?}"
3689 );
3690 }
3691
3692 #[tokio::test]
3693 async fn confirm_enqueue_with_decides_from_the_answer() {
3694 assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
3695 assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
3696 assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
3697 assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
3698 assert!(!confirm_enqueue_with(3, async { None }).await);
3699 }
3700
3701 #[tokio::test]
3702 async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
3703 let cmd = MergeQueueCommand {
3706 paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
3707 check: true,
3708 yes: false,
3709 socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
3710 };
3711 let err = cmd.execute().await.unwrap_err();
3712 assert!(
3713 err.to_string().contains("cannot resolve worktree path"),
3714 "{err}"
3715 );
3716 }
3717
3718 #[tokio::test]
3719 async fn merge_queue_check_prints_the_report_and_never_confirms() {
3720 let target = tempfile::tempdir().unwrap();
3721 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3722 "ok": true,
3723 "payload": {
3724 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3725 "skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
3726 }
3727 })]);
3728 let cmd = MergeQueueCommand {
3729 paths: vec![target.path().to_path_buf()],
3730 check: true,
3731 yes: false,
3732 socket: Some(sock),
3733 };
3734 cmd.execute_with(|_| async { panic!("must not confirm on --check") })
3736 .await
3737 .unwrap();
3738 server.await.unwrap();
3739 }
3740
3741 #[tokio::test]
3742 async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
3743 let target = tempfile::tempdir().unwrap();
3744 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3745 "ok": true,
3746 "payload": {
3747 "eligible": [],
3748 "skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
3749 }
3750 })]);
3751 let cmd = MergeQueueCommand {
3752 paths: vec![target.path().to_path_buf()],
3753 check: false,
3754 yes: false,
3755 socket: Some(sock),
3756 };
3757 cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
3759 .await
3760 .unwrap();
3761 server.await.unwrap();
3762 }
3763
3764 #[tokio::test]
3765 async fn merge_queue_aborts_when_confirmation_is_declined() {
3766 let target = tempfile::tempdir().unwrap();
3767 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3768 "ok": true,
3769 "payload": {
3770 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3771 "skipped": [],
3772 }
3773 })]);
3774 let cmd = MergeQueueCommand {
3775 paths: vec![target.path().to_path_buf()],
3776 check: false,
3777 yes: false,
3778 socket: Some(sock),
3779 };
3780 cmd.execute_with(|count| async move {
3782 assert_eq!(count, 1);
3783 false
3784 })
3785 .await
3786 .unwrap();
3787 server.await.unwrap();
3788 }
3789
3790 #[tokio::test]
3791 async fn merge_queue_enqueues_after_confirmation() {
3792 let target = tempfile::tempdir().unwrap();
3793 let (_dir, sock, server) = fake_daemon_replies(vec![
3794 json!({
3795 "ok": true,
3796 "payload": {
3797 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3798 "skipped": [],
3799 }
3800 }),
3801 json!({
3802 "ok": true,
3803 "payload": {
3804 "queued": [{ "path": "/a", "number": 9 }],
3805 "skipped": [],
3806 "failed": [],
3807 }
3808 }),
3809 ]);
3810 let cmd = MergeQueueCommand {
3811 paths: vec![target.path().to_path_buf()],
3812 check: false,
3813 yes: false,
3814 socket: Some(sock),
3815 };
3816 cmd.execute_with(|_| async { true }).await.unwrap();
3818 server.await.unwrap();
3819 }
3820
3821 #[tokio::test]
3822 async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
3823 let target = tempfile::tempdir().unwrap();
3827 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3828 "ok": true,
3829 "payload": { "eligible": [], "skipped": [] }
3830 })]);
3831 let cmd = WorktreesCommand {
3832 command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
3833 paths: vec![target.path().to_path_buf()],
3834 check: true,
3835 yes: false,
3836 socket: Some(sock),
3837 }),
3838 };
3839 cmd.execute(None).await.unwrap();
3840 server.await.unwrap();
3841 }
3842
3843 #[test]
3846 fn reposition_parses_flags_and_enforces_the_undo_split() {
3847 let WorktreesSubcommands::Reposition(cmd) = parse(&[
3848 "reposition",
3849 "--reference",
3850 "/wt/ref",
3851 "/wt/a",
3852 "/wt/b",
3853 "--dry-run",
3854 "-o",
3855 "json",
3856 ]) else {
3857 panic!("expected the Reposition variant");
3858 };
3859 assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
3860 assert_eq!(
3861 cmd.paths,
3862 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3863 );
3864 assert!(cmd.dry_run);
3865 assert!(!cmd.undo);
3866 assert_eq!(cmd.output, TableOrJson::Json);
3867
3868 let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
3870 panic!("expected the Reposition variant");
3871 };
3872 assert!(undo.undo);
3873 assert!(undo.reference.is_none());
3874 }
3875
3876 #[test]
3877 fn reposition_rejects_a_missing_reference_and_undo_combinations() {
3878 assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
3881 assert!(RepositionCommand::try_parse_from([
3884 "reposition",
3885 "--undo",
3886 "--reference",
3887 "/wt/ref",
3888 ])
3889 .is_err());
3890 assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
3891 }
3892
3893 #[test]
3894 fn window_key_for_matches_a_canonicalized_folder() {
3895 let dir = tempfile::tempdir_in("/tmp").unwrap();
3896 let wt = dir.path().join("tree");
3897 std::fs::create_dir(&wt).unwrap();
3898 let canonical = std::fs::canonicalize(&wt).unwrap();
3899 let windows = json!({
3900 "windows": [
3901 { "key": "other", "folders": ["/definitely/not/here"] },
3902 { "key": "wanted", "folders": [canonical.to_string_lossy()] },
3903 ]
3904 });
3905 assert_eq!(
3906 window_key_for(&windows, &wt, "repositioned").unwrap(),
3907 "wanted"
3908 );
3909 }
3910
3911 #[test]
3912 fn window_key_for_errors_when_no_window_has_it_open() {
3913 let dir = tempfile::tempdir_in("/tmp").unwrap();
3917 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
3918 .expect_err("an unopened worktree must not resolve");
3919 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3920 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
3923 .expect_err("an unopened worktree must not resolve");
3924 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3925 let missing = dir.path().join("gone");
3927 let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
3928 .expect_err("a nonexistent path must not resolve");
3929 assert!(err.to_string().contains("cannot resolve"), "{err:#}");
3930 }
3931
3932 #[test]
3933 fn reload_command_requires_at_least_one_path() {
3934 assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
3937 let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
3938 assert_eq!(cmd.paths.len(), 2);
3939 assert!(matches!(cmd.output, TableOrJson::Table));
3940 assert!(cmd.socket.is_none());
3941 }
3942
3943 #[test]
3944 fn render_reload_reports_what_was_signalled_not_reloaded() {
3945 let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
3948 assert_eq!(out, "Signalled 2 of 2 windows to reload.");
3949 assert!(!out.contains("Reloaded"), "{out}");
3950 let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
3952 assert_eq!(one, "Signalled 1 of 1 window to reload.");
3953 }
3954
3955 #[test]
3956 fn render_reload_names_windows_that_had_already_closed() {
3957 let out = render_reload(&json!({
3960 "requested": 3,
3961 "signalled": 1,
3962 "unknown": ["w2", "w3"],
3963 }));
3964 assert!(
3965 out.starts_with("Signalled 1 of 3 windows to reload."),
3966 "{out}"
3967 );
3968 assert!(out.contains("No longer open"), "{out}");
3969 assert!(out.contains("w2, w3"), "{out}");
3970 }
3971
3972 #[test]
3973 fn render_reload_tolerates_a_reply_missing_every_field() {
3974 assert_eq!(
3977 render_reload(&json!({})),
3978 "Signalled 0 of 0 windows to reload."
3979 );
3980 }
3981
3982 #[test]
3983 fn render_reposition_explains_a_missing_permission() {
3984 let out = render_reposition(&json!({ "trusted": false, "results": [] }));
3985 assert!(out.contains("Accessibility permission"), "{out}");
3986 assert!(out.contains("daemon restart"), "{out}");
3987 }
3988
3989 #[test]
3990 fn render_reposition_reports_a_blocked_batch() {
3991 let out = render_reposition(&json!({
3992 "trusted": true,
3993 "blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
3994 "results": [],
3995 }));
3996 assert!(out.contains("Nothing was moved"), "{out}");
3997 assert!(out.contains("reference-ambiguous"), "{out}");
3998 assert!(out.contains("2 windows match"), "{out}");
3999 }
4000
4001 #[test]
4002 fn render_reposition_renders_the_reference_and_per_target_outcomes() {
4003 let out = render_reposition(&json!({
4004 "trusted": true,
4005 "reference": {
4006 "key": "r",
4007 "title": "ref-tree",
4008 "frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
4009 },
4010 "moved": 1,
4011 "skipped": 1,
4012 "results": [
4013 { "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
4014 { "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
4015 ],
4016 }));
4017 assert!(
4018 out.contains("Reference: ref-tree 800×600 at (10, 21)"),
4019 "{out}"
4020 );
4021 assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
4022 assert!(out.contains("moved: a-tree"), "{out}");
4023 assert!(out.contains("ambiguous: b-tree"), "{out}");
4024 }
4025
4026 #[test]
4027 fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
4028 let out = render_reposition(&json!({
4031 "trusted": true,
4032 "results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
4033 }));
4034 assert!(out.contains("no-window: keyless"), "{out}");
4035 let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
4037 assert!(empty.contains("(nothing to report)"), "{empty}");
4038 }
4039
4040 #[test]
4041 fn render_reposition_strips_control_bytes_from_daemon_strings() {
4042 let out = render_reposition(&json!({
4045 "trusted": true,
4046 "results": [{
4047 "key": "k",
4048 "title": "evil\u{1b}[31mred",
4049 "outcome": "moved",
4050 "detail": "ok\u{7}",
4051 }],
4052 }));
4053 assert!(!out.contains('\u{1b}'), "{out:?}");
4054 assert!(!out.contains('\u{7}'), "{out:?}");
4055 }
4056
4057 #[test]
4058 fn render_frame_formats_or_dashes() {
4059 assert_eq!(render_frame(None), "-");
4060 assert_eq!(
4061 render_frame(Some(
4062 &json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
4063 )),
4064 "100×50 at (2, -2)"
4065 );
4066 assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
4068 }
4069
4070 #[test]
4071 fn print_reposition_emits_both_formats() {
4072 let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
4073 print_reposition(TableOrJson::Table, &reply).unwrap();
4074 print_reposition(TableOrJson::Json, &reply).unwrap();
4075 }
4076
4077 #[tokio::test]
4078 async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
4079 let dir = tempfile::tempdir_in("/tmp").unwrap();
4080 let reference = dir.path().join("ref");
4081 let target = dir.path().join("tgt");
4082 std::fs::create_dir(&reference).unwrap();
4083 std::fs::create_dir(&target).unwrap();
4084 let (canon_ref, canon_tgt) = (
4085 std::fs::canonicalize(&reference).unwrap(),
4086 std::fs::canonicalize(&target).unwrap(),
4087 );
4088
4089 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4092 json!({ "ok": true, "payload": { "windows": [
4093 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4094 { "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
4095 ] } }),
4096 json!({ "ok": true, "payload": {
4097 "trusted": true,
4098 "moved": 1,
4099 "skipped": 0,
4100 "results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
4101 } }),
4102 ]);
4103
4104 RepositionCommand {
4105 paths: vec![target],
4106 reference: Some(reference),
4107 dry_run: false,
4108 undo: false,
4109 output: TableOrJson::Json,
4110 socket: Some(sock),
4111 }
4112 .execute()
4113 .await
4114 .unwrap();
4115 server.await.unwrap();
4116 }
4117
4118 #[tokio::test]
4119 async fn reposition_undo_skips_the_list_lookup_entirely() {
4120 let (_dir, sock, server) = fake_daemon_reply(json!({
4123 "ok": true,
4124 "payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
4125 }));
4126 RepositionCommand {
4127 paths: Vec::new(),
4128 reference: None,
4129 dry_run: false,
4130 undo: true,
4131 output: TableOrJson::Table,
4132 socket: Some(sock),
4133 }
4134 .execute()
4135 .await
4136 .unwrap();
4137 server.await.unwrap();
4138 }
4139
4140 #[tokio::test]
4141 async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
4142 let dir = tempfile::tempdir_in("/tmp").unwrap();
4143 let reference = dir.path().join("ref");
4144 let target = dir.path().join("tgt");
4145 std::fs::create_dir(&reference).unwrap();
4146 std::fs::create_dir(&target).unwrap();
4147 let canon_ref = std::fs::canonicalize(&reference).unwrap();
4148
4149 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4152 "ok": true,
4153 "payload": { "windows": [
4154 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4155 ] },
4156 }));
4157 let err = RepositionCommand {
4158 paths: vec![target],
4159 reference: Some(reference),
4160 dry_run: true,
4161 undo: false,
4162 output: TableOrJson::Table,
4163 socket: Some(sock),
4164 }
4165 .execute()
4166 .await
4167 .expect_err("an unopened target must abort the command");
4168 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
4169 server.await.unwrap();
4170 }
4171
4172 #[tokio::test]
4173 async fn reposition_surfaces_a_daemon_error() {
4174 let (_dir, sock, server) = fake_daemon_reply(json!({
4175 "ok": false,
4176 "error": "unknown worktrees op: reposition",
4177 }));
4178 let err = RepositionCommand {
4179 paths: Vec::new(),
4180 reference: None,
4181 dry_run: false,
4182 undo: true,
4183 output: TableOrJson::Table,
4184 socket: Some(sock),
4185 }
4186 .execute()
4187 .await
4188 .expect_err("an `ok:false` reply must not be reported as success");
4189 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4190 server.await.unwrap();
4191 }
4192
4193 fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
4196 let dir = tempfile::tempdir_in("/tmp").unwrap();
4197 let a = dir.path().join("a");
4198 let b = dir.path().join("b");
4199 std::fs::create_dir(&a).unwrap();
4200 std::fs::create_dir(&b).unwrap();
4201 let list = json!({ "ok": true, "payload": { "windows": [
4202 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4203 { "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
4204 ] } });
4205 (dir, a, b, list)
4206 }
4207
4208 #[tokio::test]
4209 async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
4210 let (_dir, a, b, list) = reload_fixture();
4211 let (_sock_dir, sock, server) = fake_daemon_seq(vec![
4214 list,
4215 json!({ "ok": true, "payload": {
4216 "requested": 2, "signalled": 2, "unknown": [],
4217 } }),
4218 ]);
4219
4220 ReloadCommand {
4221 paths: vec![a, b],
4222 output: TableOrJson::Table,
4223 socket: Some(sock),
4224 }
4225 .execute()
4226 .await
4227 .unwrap();
4228
4229 let requests = server.await.unwrap();
4232 assert_eq!(requests[1]["op"], "reload");
4233 assert_eq!(
4234 requests[1]["payload"]["target_keys"],
4235 json!(["key-a", "key-b"])
4236 );
4237 assert!(
4238 requests[1]["payload"].get("requester_key").is_none(),
4239 "a CLI process is not a window, so it must not claim to be one"
4240 );
4241 }
4242
4243 #[tokio::test]
4244 async fn reload_json_output_passes_the_reply_through_verbatim() {
4245 let (_dir, a, _b, list) = reload_fixture();
4246 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4247 list,
4248 json!({ "ok": true, "payload": {
4249 "requested": 1, "signalled": 0, "unknown": ["key-a"],
4250 } }),
4251 ]);
4252 ReloadCommand {
4255 paths: vec![a],
4256 output: TableOrJson::Json,
4257 socket: Some(sock),
4258 }
4259 .execute()
4260 .await
4261 .unwrap();
4262 server.await.unwrap();
4263 }
4264
4265 #[tokio::test]
4266 async fn reload_fails_before_the_op_when_a_target_has_no_window() {
4267 let (_dir, a, b, _list) = reload_fixture();
4268 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4272 "ok": true,
4273 "payload": { "windows": [
4274 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4275 ] },
4276 }));
4277 let err = ReloadCommand {
4278 paths: vec![a, b],
4279 output: TableOrJson::Table,
4280 socket: Some(sock),
4281 }
4282 .execute()
4283 .await
4284 .expect_err("an unopened target must abort the command");
4285 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
4286 server.await.unwrap();
4287 }
4288
4289 #[tokio::test]
4290 async fn reload_surfaces_a_daemon_error() {
4291 let (_dir, a, _b, list) = reload_fixture();
4292 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4293 list,
4294 json!({ "ok": false, "error": "unknown worktrees op: reload" }),
4295 ]);
4296 let err = ReloadCommand {
4299 paths: vec![a],
4300 output: TableOrJson::Table,
4301 socket: Some(sock),
4302 }
4303 .execute()
4304 .await
4305 .expect_err("an `ok:false` reply must not be reported as success");
4306 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4307 server.await.unwrap();
4308 }
4309
4310 fn push_cmd() -> PushCommand {
4314 PushCommand {
4315 paths: Vec::new(),
4316 all: false,
4317 dry_run: false,
4318 yes: false,
4319 output: TableOrJson::Table,
4320 }
4321 }
4322
4323 #[test]
4324 fn push_parses_paths_and_flags() {
4325 let cmd = PushCommand::try_parse_from(["push", "/a", "/b", "--dry-run", "-y"]).unwrap();
4326 assert_eq!(cmd.paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
4327 assert!(cmd.dry_run && cmd.yes);
4328 assert!(!cmd.all);
4329 }
4330
4331 #[test]
4332 fn push_exposes_no_force_escape_hatch() {
4333 for flag in ["--force", "-f", "--no-force-if-includes"] {
4336 assert!(
4337 PushCommand::try_parse_from(["push", "/a", flag]).is_err(),
4338 "{flag} must not be accepted"
4339 );
4340 }
4341 }
4342
4343 #[test]
4344 fn push_selection_requires_paths_or_all() {
4345 let err = push_cmd().selection(None).unwrap_err().to_string();
4346 assert!(err.contains("--all"), "{err}");
4347
4348 let both = PushCommand {
4349 paths: vec![PathBuf::from("/a")],
4350 all: true,
4351 ..push_cmd()
4352 };
4353 let err = both.selection(None).unwrap_err().to_string();
4354 assert!(err.contains("not both"), "{err}");
4355 }
4356
4357 #[test]
4358 fn push_selection_resolves_relative_paths_against_the_repo_flag() {
4359 let cmd = PushCommand {
4360 paths: vec![PathBuf::from("wt-a"), PathBuf::from("/abs/wt-b")],
4361 ..push_cmd()
4362 };
4363 let Selection::Paths(paths) = cmd.selection(Some(Path::new("/base"))).unwrap() else {
4364 panic!("expected an explicit path selection");
4365 };
4366 assert_eq!(
4367 paths,
4368 vec![PathBuf::from("/base/wt-a"), PathBuf::from("/abs/wt-b")],
4369 "a relative path resolves against -C, an absolute one is left alone"
4370 );
4371 }
4372
4373 #[tokio::test]
4374 async fn push_dry_run_reaches_no_remote_and_pushes_nothing() {
4375 let dir = tempfile::tempdir().unwrap();
4378 PushCommand {
4379 paths: vec![dir.path().to_path_buf()],
4380 dry_run: true,
4381 ..push_cmd()
4382 }
4383 .execute_with(None, |_, _| async { panic!("a dry run must not confirm") })
4384 .await
4385 .unwrap();
4386 }
4387
4388 #[tokio::test]
4389 async fn push_declining_the_confirmation_publishes_nothing() {
4390 let (_root, origin, wt) = push_scenario();
4391 let before = origin_tip(&origin, "refs/heads/feature");
4392
4393 PushCommand {
4394 paths: vec![wt],
4395 ..push_cmd()
4396 }
4397 .execute_with(None, |pending, forced| async move {
4398 assert_eq!((pending, forced), (1, 1));
4399 false
4400 })
4401 .await
4402 .unwrap();
4403
4404 assert_eq!(
4405 origin_tip(&origin, "refs/heads/feature"),
4406 before,
4407 "declining must leave the remote exactly as it was"
4408 );
4409 }
4410
4411 #[tokio::test]
4412 async fn push_confirming_force_pushes_with_the_lease() {
4413 let (_root, origin, wt) = push_scenario();
4414 let rewritten = git2::Repository::open(&wt)
4415 .unwrap()
4416 .head()
4417 .unwrap()
4418 .target();
4419
4420 PushCommand {
4421 paths: vec![wt],
4422 ..push_cmd()
4423 }
4424 .execute_with(None, |_, _| async { true })
4425 .await
4426 .unwrap();
4427
4428 assert_eq!(
4429 origin_tip(&origin, "refs/heads/feature"),
4430 rewritten,
4431 "confirming publishes the rewritten tip"
4432 );
4433 }
4434
4435 #[test]
4436 fn push_prompt_calls_out_the_force_count_separately() {
4437 assert_eq!(push_prompt(1, 0), "Push 1 branch? [y/N] ");
4438 assert_eq!(push_prompt(3, 0), "Push 3 branches? [y/N] ");
4439
4440 let forced = push_prompt(3, 2);
4441 assert!(forced.contains("force-pushing 2 with a lease"), "{forced}");
4442 assert!(
4443 forced.contains("rewritten history"),
4444 "the prompt must say what is actually being published: {forced}"
4445 );
4446 }
4447
4448 #[tokio::test]
4449 async fn push_confirmation_treats_anything_but_yes_as_no() {
4450 assert!(confirm_push_with(1, 1, async { Some("y\n".into()) }).await);
4451 assert!(confirm_push_with(1, 1, async { Some("YES".into()) }).await);
4452 assert!(!confirm_push_with(1, 1, async { Some("n".into()) }).await);
4453 assert!(
4454 !confirm_push_with(1, 1, async { None }).await,
4455 "EOF must never be read as consent"
4456 );
4457 }
4458
4459 #[test]
4460 fn push_rows_render_each_status_with_its_own_instruction() {
4461 let outcome = |result| worktree_push::WorktreeOutcome {
4462 path: PathBuf::from("/wt"),
4463 branch: Some("feature".into()),
4464 remote: "origin".into(),
4465 remote_branch: "feature".into(),
4466 result,
4467 };
4468 let rendered = render_push_outcomes(&[
4469 outcome(worktree_push::PushResult::WouldForce {
4470 ahead: 2,
4471 behind: 1,
4472 }),
4473 outcome(worktree_push::PushResult::Rejected {
4474 detail: "stale info".into(),
4475 stale: true,
4476 }),
4477 outcome(worktree_push::PushResult::Skipped {
4478 reason: worktree_push::SkipReason::DefaultBranchForcePush,
4479 }),
4480 ]);
4481 assert!(rendered.contains("would-force"), "{rendered}");
4482 assert!(rendered.contains("origin/feature"), "{rendered}");
4483 assert!(
4484 rendered.contains("`git fetch` and rebase"),
4485 "a lease refusal must name the fix, not just quote git: {rendered}"
4486 );
4487 assert!(
4488 rendered.contains("refusing to force-push the remote default branch"),
4489 "{rendered}"
4490 );
4491 }
4492
4493 #[test]
4494 fn push_renders_an_empty_selection_without_a_bare_header() {
4495 assert_eq!(render_push_outcomes(&[]), "No worktrees selected.");
4496 }
4497
4498 #[test]
4499 fn push_rows_render_every_remaining_status_and_skip_reason() {
4500 use worktree_push::{PushResult, SkipReason};
4504 let outcome = |result| worktree_push::WorktreeOutcome {
4505 path: PathBuf::from("/wt"),
4506 branch: Some("feature".into()),
4507 remote: "origin".into(),
4508 remote_branch: "feature".into(),
4509 result,
4510 };
4511 let rendered = render_push_outcomes(&[
4512 outcome(PushResult::UpToDate),
4513 outcome(PushResult::WouldFastForward { ahead: 3 }),
4514 outcome(PushResult::WouldCreate),
4515 outcome(PushResult::Pushed { forced: true }),
4516 outcome(PushResult::Pushed { forced: false }),
4517 outcome(PushResult::Created),
4518 outcome(PushResult::Rejected {
4519 detail: "pre-receive hook declined".into(),
4520 stale: false,
4521 }),
4522 outcome(PushResult::Skipped {
4523 reason: SkipReason::DetachedHead,
4524 }),
4525 outcome(PushResult::Skipped {
4526 reason: SkipReason::NotAWorktree,
4527 }),
4528 outcome(PushResult::Skipped {
4529 reason: SkipReason::NoRemote,
4530 }),
4531 ]);
4532
4533 for expected in [
4534 "up-to-date",
4535 "3 ahead; fast-forward",
4536 "no upstream yet",
4537 "forced with lease",
4538 "fast-forward",
4539 "upstream set",
4540 "pre-receive hook declined",
4541 "detached HEAD",
4542 "not a git worktree",
4543 "no remote to publish to",
4544 ] {
4545 assert!(
4546 rendered.contains(expected),
4547 "missing {expected:?}: {rendered}"
4548 );
4549 }
4550 assert!(
4551 !rendered.contains("`git fetch` and rebase"),
4552 "only a *lease* refusal earns the fetch-and-rebase instruction: {rendered}"
4553 );
4554 }
4555
4556 #[test]
4557 fn push_rows_render_an_unresolved_destination_as_a_dash() {
4558 let rendered = render_push_outcomes(&[worktree_push::WorktreeOutcome {
4561 path: PathBuf::from("/wt"),
4562 branch: None,
4563 remote: String::new(),
4564 remote_branch: String::new(),
4565 result: worktree_push::PushResult::Skipped {
4566 reason: worktree_push::SkipReason::NotAWorktree,
4567 },
4568 }]);
4569 let row = rendered.lines().nth(1).unwrap();
4570 assert!(
4571 row.contains(" - "),
4572 "branch and remote both render as `-`: {row}"
4573 );
4574 }
4575
4576 #[test]
4577 fn push_all_selects_the_repository_rather_than_named_paths() {
4578 let cmd = PushCommand {
4579 all: true,
4580 ..push_cmd()
4581 };
4582 let selection = cmd.selection(Some(Path::new("/base"))).unwrap();
4583 match selection {
4584 Selection::All { base } => assert_eq!(base, PathBuf::from("/base")),
4585 other @ Selection::Paths(_) => panic!("expected an --all selection, got {other:?}"),
4586 }
4587 }
4588
4589 #[tokio::test]
4590 async fn push_json_output_carries_the_dry_run_flag_and_the_outcomes() {
4591 let (_root, _origin, wt) = push_scenario();
4594 let cmd = PushCommand {
4595 paths: vec![wt.clone()],
4596 dry_run: true,
4597 output: TableOrJson::Json,
4598 ..push_cmd()
4599 };
4600 cmd.execute_with(None, |_, _| async { false })
4603 .await
4604 .expect("a dry run must succeed");
4605 }
4606
4607 fn push_scenario() -> (tempfile::TempDir, PathBuf, PathBuf) {
4611 let _guard = crate::git::worktree_batch::test_serial_lock();
4614 let root = tempfile::tempdir().unwrap();
4615 let origin = root.path().join("origin.git");
4616 let local = root.path().join("local");
4617 let wt = root.path().join("feature-wt");
4618 std::fs::create_dir_all(&origin).unwrap();
4619 std::fs::create_dir_all(&local).unwrap();
4620
4621 let git = |dir: &Path, args: &[&str]| {
4622 let out = crate::git::worktree_batch::run_git_in(
4623 &crate::git::resolve_git_binary(),
4624 dir,
4625 args,
4626 )
4627 .unwrap();
4628 assert!(
4629 out.status.success(),
4630 "git {args:?} failed: {}",
4631 String::from_utf8_lossy(&out.stderr)
4632 );
4633 };
4634
4635 git(&origin, &["init", "--bare", "-b", "main"]);
4636 git(&local, &["init", "-b", "main"]);
4637 git(&local, &["config", "user.name", "Test"]);
4638 git(&local, &["config", "user.email", "test@example.com"]);
4639 git(&local, &["config", "commit.gpgsign", "false"]);
4640 std::fs::write(local.join("f.txt"), "base\n").unwrap();
4641 git(&local, &["add", "f.txt"]);
4642 git(&local, &["commit", "-m", "base"]);
4643 git(
4644 &local,
4645 &["remote", "add", "origin", origin.to_str().unwrap()],
4646 );
4647 git(&local, &["push", "-u", "origin", "main"]);
4648 git(
4649 &local,
4650 &[
4651 "worktree",
4652 "add",
4653 "-b",
4654 "feature",
4655 wt.to_str().unwrap(),
4656 "main",
4657 ],
4658 );
4659 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
4660 git(&wt, &["add", "g.txt"]);
4661 git(&wt, &["commit", "-m", "work"]);
4662 git(&wt, &["push", "-u", "origin", "feature"]);
4663 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
4664
4665 (root, origin, std::fs::canonicalize(&wt).unwrap())
4666 }
4667
4668 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
4670 git2::Repository::open_bare(origin)
4671 .unwrap()
4672 .refname_to_id(refname)
4673 .ok()
4674 }
4675}