1mod ui;
13
14use std::path::{Path, PathBuf};
15
16use anyhow::{bail, Context, Result};
17use chrono::Utc;
18use clap::{Parser, Subcommand};
19use serde_json::{json, Value};
20
21use crate::cli::format::{sanitize_for_terminal, TableOrJson};
22use crate::daemon::client::DaemonClient;
23use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
24use crate::daemon::server;
25use crate::git::worktree_batch::Selection;
26use crate::git::worktree_push;
27use crate::git::worktree_rebase::{
28 self, FetchOutcome, RebaseOptions, RebaseResult, SkipReason, WorktreeOutcome,
29};
30
31const SERVICE: &str = "worktrees";
33
34#[derive(Parser)]
37pub struct WorktreesCommand {
38 #[command(subcommand)]
40 pub command: WorktreesSubcommands,
41}
42
43#[derive(Subcommand)]
45pub enum WorktreesSubcommands {
46 List(ListCommand),
48 Tree(TreeCommand),
50 Focus(FocusCommand),
52 Close(CloseCommand),
54 Rebase(RebaseCommand),
56 Push(PushCommand),
58 MergeQueue(MergeQueueCommand),
60 Reposition(RepositionCommand),
62 Reload(ReloadCommand),
64 ShowClosed(ShowClosedCommand),
66 Register(RegisterCommand),
68 Heartbeat(HeartbeatCommand),
70 Unregister(UnregisterCommand),
72 Ui(ui::UiCommand),
74}
75
76impl WorktreesCommand {
77 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
85 match self.command {
86 WorktreesSubcommands::List(cmd) => cmd.execute().await,
87 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
88 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
89 WorktreesSubcommands::Close(cmd) => cmd.execute().await,
90 WorktreesSubcommands::Rebase(cmd) => cmd.execute(repo).await,
91 WorktreesSubcommands::Push(cmd) => cmd.execute(repo).await,
92 WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
93 WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
94 WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
95 WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
96 WorktreesSubcommands::Register(cmd) => cmd.execute().await,
97 WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
98 WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
99 WorktreesSubcommands::Ui(cmd) => cmd.execute().await,
100 }
101 }
102}
103
104#[derive(Parser)]
106pub struct ListCommand {
107 #[arg(long, value_name = "PATH")]
109 pub socket: Option<PathBuf>,
110 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
112 pub output: TableOrJson,
113 #[arg(long, hide = true)]
115 pub json: bool,
116}
117
118impl ListCommand {
119 pub async fn execute(mut self) -> Result<()> {
121 if self.json {
122 eprintln!("warning: --json is deprecated; use -o/--output json instead");
123 self.output = TableOrJson::Json;
124 }
125 let socket = server::resolve_socket(self.socket)?;
126 let result = call(&socket, "list", Value::Null).await?;
127 match self.output {
128 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
129 TableOrJson::Table => println!("{}", render_windows(&result)),
130 }
131 Ok(())
132 }
133}
134
135#[derive(Parser)]
139pub struct TreeCommand {
140 #[arg(long, value_name = "PATH")]
142 pub socket: Option<PathBuf>,
143 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
145 pub output: TableOrJson,
146 #[arg(short = 'f', long)]
149 pub follow: bool,
150}
151
152impl TreeCommand {
153 pub async fn execute(self) -> Result<()> {
155 let socket = server::resolve_socket(self.socket)?;
156 if self.follow {
157 return follow_tree_stream(&socket, self.output).await;
158 }
159 let mut result = call(&socket, "tree", Value::Null).await?;
160 enrich_ahead_behind(&socket, &mut result).await;
166 match self.output {
167 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
168 TableOrJson::Table => println!("{}", render_tree(&result)),
169 }
170 Ok(())
171 }
172}
173
174async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
181 let mut sub = DaemonClient::new(socket)
182 .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
183 .await?;
184 loop {
185 tokio::select! {
186 frame = sub.next() => {
187 let Some(frame) = frame else { break };
189 let mut payload = reply_payload(frame?)?;
190 enrich_ahead_behind(socket, &mut payload).await;
194 match output {
195 TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
197 TableOrJson::Table => println!("{}", render_tree(&payload)),
198 }
199 }
200 _ = tokio::signal::ctrl_c() => break,
203 }
204 }
205 Ok(())
206}
207
208#[derive(Parser)]
215pub struct FocusCommand {
216 #[arg(value_name = "PATH")]
218 pub path: PathBuf,
219 #[arg(long, value_name = "PATH")]
221 pub socket: Option<PathBuf>,
222}
223
224impl FocusCommand {
225 pub async fn execute(self) -> Result<()> {
227 let path = std::fs::canonicalize(&self.path)
231 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
232 let socket = server::resolve_socket(self.socket)?;
233 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
234 println!("Focused {}", path.display());
235 Ok(())
236 }
237}
238
239#[derive(Parser)]
248pub struct CloseCommand {
249 #[arg(value_name = "PATH")]
252 pub path: PathBuf,
253 #[arg(long)]
255 pub window_only: bool,
256 #[arg(long)]
258 pub dry_run: bool,
259 #[arg(short = 'y', long)]
261 pub yes: bool,
262 #[arg(long, value_name = "PATH")]
264 pub socket: Option<PathBuf>,
265}
266
267impl CloseCommand {
268 pub async fn execute(self) -> Result<()> {
270 self.execute_with(confirm_removal).await
271 }
272
273 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
278 where
279 F: FnOnce(bool) -> Fut,
280 Fut: std::future::Future<Output = bool>,
281 {
282 let path = std::fs::canonicalize(&self.path)
285 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
286 let path_str = path.to_string_lossy().to_string();
287 let socket = server::resolve_socket(self.socket)?;
288
289 if self.window_only {
293 if self.dry_run {
294 println!(
295 "Would close the window for {} (dry run; nothing closed)",
296 path.display()
297 );
298 return Ok(());
299 }
300 call(
301 &socket,
302 "close",
303 json!({ "path": path_str, "remove": false }),
304 )
305 .await?;
306 println!("Closed the window for {}", path.display());
307 return Ok(());
308 }
309
310 let report = call(
312 &socket,
313 "close",
314 json!({ "path": path_str, "remove": true }),
315 )
316 .await?;
317 println!("{}", render_safety_report(&path, &report));
318
319 if self.dry_run {
320 return Ok(());
321 }
322 if report.get("removable").and_then(Value::as_bool) != Some(true) {
325 bail!(
326 "{} is not a removable worktree (nothing deleted); \
327 use --window-only to just close its window",
328 path.display()
329 );
330 }
331 let has_risks = report
332 .get("risks")
333 .and_then(Value::as_array)
334 .is_some_and(|r| !r.is_empty());
335 if !self.yes && !confirm(has_risks).await {
336 println!("Aborted; nothing was deleted.");
337 return Ok(());
338 }
339
340 call(
342 &socket,
343 "close",
344 json!({ "path": path_str, "remove": true, "confirmed": true }),
345 )
346 .await?;
347 println!("Deleted worktree {}", path.display());
348 Ok(())
349 }
350}
351
352#[derive(Parser)]
366pub struct RebaseCommand {
367 #[arg(value_name = "PATH")]
370 pub paths: Vec<PathBuf>,
371 #[arg(long)]
374 pub all: bool,
375 #[arg(long, value_name = "REF")]
378 pub onto: Option<String>,
379 #[arg(long)]
382 pub autostash: bool,
383 #[arg(long)]
385 pub dry_run: bool,
386 #[arg(long)]
389 pub keep_conflicts: bool,
390 #[arg(short = 'y', long)]
392 pub yes: bool,
393 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
395 pub output: TableOrJson,
396}
397
398impl RebaseCommand {
399 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
401 self.execute_with(repo, confirm_rebase).await
402 }
403
404 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
409 where
410 F: FnOnce(usize) -> Fut,
411 Fut: std::future::Future<Output = bool>,
412 {
413 let selection = self.selection(repo)?;
414 let opts = RebaseOptions {
415 onto: self.onto.clone(),
416 autostash: self.autostash,
417 dry_run: self.dry_run,
418 keep_conflicts: self.keep_conflicts,
419 git_bin: None,
423 };
424
425 let plan_opts = opts.clone();
428 let plan =
429 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
430 .await
431 .context("rebase planning task panicked")??;
432
433 let json = matches!(self.output, TableOrJson::Json);
434 if self.dry_run || !plan.has_pending_rebases() {
438 self.print(json, &plan.fetches, &plan.worktrees)?;
439 return Ok(());
440 }
441
442 if !json {
444 println!("{}", render_fetches(&plan.fetches));
445 println!("{}", render_outcomes(&plan.worktrees));
446 }
447 let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
448 if !self.yes && !confirm(pending).await {
449 println!("Aborted; no worktree was rebased.");
450 return Ok(());
451 }
452
453 let fetches = plan.fetches.clone();
454 let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
455 .await
456 .context("rebase task panicked")?;
457 if !json {
458 println!();
459 }
460 self.print(json, &fetches, &outcomes)
461 }
462
463 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
471 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
472 if self.all {
473 if !self.paths.is_empty() {
474 bail!("pass either <PATH>... or --all, not both");
475 }
476 return Ok(Selection::All { base });
477 }
478 if self.paths.is_empty() {
479 bail!(
480 "specify one or more <PATH> arguments, or --all to rebase \
481 every worktree of this repository"
482 );
483 }
484 let paths = self
485 .paths
486 .iter()
487 .map(|path| {
488 if path.is_absolute() {
489 path.clone()
490 } else {
491 base.join(path)
492 }
493 })
494 .collect();
495 Ok(Selection::Paths(paths))
496 }
497
498 fn print(
500 &self,
501 json: bool,
502 fetches: &[FetchOutcome],
503 outcomes: &[WorktreeOutcome],
504 ) -> Result<()> {
505 if json {
506 let value = json!({
507 "dry_run": self.dry_run,
508 "fetches": fetches,
509 "worktrees": outcomes,
510 });
511 println!("{}", serde_json::to_string_pretty(&value)?);
512 } else {
513 println!("{}", render_fetches(fetches));
514 println!("{}", render_outcomes(outcomes));
515 }
516 Ok(())
517 }
518}
519
520fn is_pending(outcome: &WorktreeOutcome) -> bool {
522 matches!(outcome.result, RebaseResult::WouldRebase { .. })
523}
524
525fn render_fetches(fetches: &[FetchOutcome]) -> String {
528 if fetches.is_empty() {
529 return "No repository selected.".to_string();
530 }
531 fetches
532 .iter()
533 .map(fetch_line)
534 .collect::<Vec<_>>()
535 .join("\n")
536}
537
538fn fetch_line(fetch: &FetchOutcome) -> String {
540 let root = sanitize(&fetch.repo_root.display().to_string());
541 let onto = sanitize(&fetch.onto);
542 if !fetch.fetched {
543 return format!("Using {onto} in {root} (local ref; nothing fetched)");
544 }
545 if fetch.ok {
546 format!("Fetched {onto} once for {root}")
547 } else {
548 let detail = brief(fetch.detail.as_deref().unwrap_or(""));
549 format!("Fetch of {onto} FAILED for {root}: {detail}")
550 }
551}
552
553fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
555 if outcomes.is_empty() {
556 return "No worktrees selected.".to_string();
557 }
558 let mut out = format!(
559 "{:<12} {:<24} {:<16} {}",
560 "STATUS", "BRANCH", "ONTO", "WORKTREE"
561 );
562 for outcome in outcomes {
563 out.push('\n');
564 out.push_str(&outcome_row(outcome));
565 }
566 out
567}
568
569fn outcome_row(outcome: &WorktreeOutcome) -> String {
571 let (status, detail) = status_and_detail(&outcome.result);
572 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
573 let onto = sanitize(&outcome.onto);
574 let path = sanitize(&outcome.path.display().to_string());
575 let suffix = if detail.is_empty() {
576 String::new()
577 } else {
578 format!(" ({detail})")
579 };
580 format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
581}
582
583fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
585 match result {
586 RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
587 RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
588 RebaseResult::UpToDate => ("up-to-date", String::new()),
589 RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
590 RebaseResult::Conflict {
594 detail,
595 left_in_place: true,
596 } => (
597 "conflict",
598 format!(
599 "left in place; resolve then `git rebase --continue`: {}",
600 brief(detail)
601 ),
602 ),
603 RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
604 RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
605 }
606}
607
608fn skip_reason_text(reason: SkipReason) -> &'static str {
610 match reason {
611 SkipReason::DetachedHead => "detached HEAD",
612 SkipReason::Dirty => "uncommitted changes; pass --autostash",
613 SkipReason::OperationInProgress => "a rebase/merge is already in progress",
614 SkipReason::NotAWorktree => "not a git worktree",
615 SkipReason::NoOntoRef => "could not resolve the target ref",
616 }
617}
618
619fn brief(detail: &str) -> String {
622 let first = detail
623 .lines()
624 .find(|line| !line.trim().is_empty())
625 .unwrap_or("");
626 let clean = sanitize(first.trim());
627 if clean.chars().count() > 100 {
628 let truncated: String = clean.chars().take(97).collect();
629 format!("{truncated}...")
630 } else {
631 clean
632 }
633}
634
635async fn confirm_rebase(pending: usize) -> bool {
637 confirm_rebase_with(pending, read_stdin_line()).await
638}
639
640async fn confirm_rebase_with(
644 pending: usize,
645 read: impl std::future::Future<Output = Option<String>>,
646) -> bool {
647 use std::io::Write;
648 eprint!("{}", rebase_prompt(pending));
649 let _ = std::io::stderr().flush();
650 read.await.as_deref().is_some_and(answer_is_yes)
651}
652
653fn rebase_prompt(pending: usize) -> String {
656 let noun = if pending == 1 {
657 "worktree"
658 } else {
659 "worktrees"
660 };
661 format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
662}
663
664#[derive(Parser)]
684pub struct PushCommand {
685 #[arg(value_name = "PATH")]
688 pub paths: Vec<PathBuf>,
689 #[arg(long)]
692 pub all: bool,
693 #[arg(long)]
695 pub dry_run: bool,
696 #[arg(short = 'y', long)]
698 pub yes: bool,
699 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
701 pub output: TableOrJson,
702}
703
704impl PushCommand {
705 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
707 self.execute_with(repo, confirm_push).await
708 }
709
710 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
715 where
716 F: FnOnce(usize, usize) -> Fut,
717 Fut: std::future::Future<Output = bool>,
718 {
719 let selection = self.selection(repo)?;
720
721 let plan = tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
726 .await
727 .context("push planning task panicked")??;
728
729 let json = matches!(self.output, TableOrJson::Json);
730 if self.dry_run || !plan.has_pending_pushes() {
732 return self.print(json, &plan.worktrees);
733 }
734
735 if !json {
738 println!("{}", render_push_outcomes(&plan.worktrees));
739 }
740 let pending = plan
741 .worktrees
742 .iter()
743 .filter(|w| w.result.is_pending())
744 .count();
745 let forced = plan
746 .worktrees
747 .iter()
748 .filter(|w| matches!(w.result, worktree_push::PushResult::WouldForce { .. }))
749 .count();
750 if !self.yes && !confirm(pending, forced).await {
751 println!("Aborted; nothing was pushed.");
752 return Ok(());
753 }
754
755 let opts = worktree_push::PushOptions::default();
759 let outcomes = tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts))
760 .await
761 .context("push task panicked")?;
762 if !json {
763 println!();
764 }
765 self.print(json, &outcomes)
766 }
767
768 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
771 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
772 if self.all {
773 if !self.paths.is_empty() {
774 bail!("pass either <PATH>... or --all, not both");
775 }
776 return Ok(Selection::All { base });
777 }
778 if self.paths.is_empty() {
779 bail!(
780 "specify one or more <PATH> arguments, or --all to publish \
781 every worktree of this repository"
782 );
783 }
784 let paths = self
785 .paths
786 .iter()
787 .map(|path| {
788 if path.is_absolute() {
789 path.clone()
790 } else {
791 base.join(path)
792 }
793 })
794 .collect();
795 Ok(Selection::Paths(paths))
796 }
797
798 fn print(&self, json: bool, outcomes: &[worktree_push::WorktreeOutcome]) -> Result<()> {
800 if json {
801 let value = json!({ "dry_run": self.dry_run, "worktrees": outcomes });
802 println!("{}", serde_json::to_string_pretty(&value)?);
803 } else {
804 println!("{}", render_push_outcomes(outcomes));
805 }
806 Ok(())
807 }
808}
809
810fn render_push_outcomes(outcomes: &[worktree_push::WorktreeOutcome]) -> String {
812 if outcomes.is_empty() {
813 return "No worktrees selected.".to_string();
814 }
815 let mut out = format!(
816 "{:<14} {:<24} {:<20} {}",
817 "STATUS", "BRANCH", "REMOTE", "WORKTREE"
818 );
819 for outcome in outcomes {
820 out.push('\n');
821 out.push_str(&push_outcome_row(outcome));
822 }
823 out
824}
825
826fn push_outcome_row(outcome: &worktree_push::WorktreeOutcome) -> String {
828 let (status, detail) = push_status_and_detail(&outcome.result);
829 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
830 let destination = if outcome.remote.is_empty() {
831 "-".to_string()
832 } else {
833 sanitize(&format!("{}/{}", outcome.remote, outcome.remote_branch))
834 };
835 let path = sanitize(&outcome.path.display().to_string());
836 let suffix = if detail.is_empty() {
837 String::new()
838 } else {
839 format!(" ({detail})")
840 };
841 format!("{status:<14} {branch:<24} {destination:<20} {path}{suffix}")
842}
843
844fn push_status_and_detail(result: &worktree_push::PushResult) -> (&'static str, String) {
846 use worktree_push::PushResult;
847 match result {
848 PushResult::UpToDate => ("up-to-date", String::new()),
849 PushResult::WouldFastForward { ahead } => {
850 ("would-push", format!("{ahead} ahead; fast-forward"))
851 }
852 PushResult::WouldForce { ahead, behind } => (
853 "would-force",
854 format!("{ahead} ahead, {behind} behind; needs --force-with-lease"),
855 ),
856 PushResult::WouldCreate => ("would-create", "no upstream yet".to_string()),
857 PushResult::Pushed { forced: true } => ("pushed", "forced with lease".to_string()),
858 PushResult::Pushed { forced: false } => ("pushed", "fast-forward".to_string()),
859 PushResult::Created => ("created", "upstream set".to_string()),
860 PushResult::Rejected { detail, stale: true } => (
864 "rejected",
865 format!(
866 "the remote moved since you last fetched; run `git fetch` and rebase, then retry: {}",
867 brief(detail)
868 ),
869 ),
870 PushResult::Rejected { detail, .. } => ("rejected", brief(detail)),
871 PushResult::Skipped { reason } => ("skipped", push_skip_reason_text(*reason).to_string()),
872 }
873}
874
875fn push_skip_reason_text(reason: worktree_push::SkipReason) -> &'static str {
877 use worktree_push::SkipReason;
878 match reason {
879 SkipReason::DetachedHead => "detached HEAD",
880 SkipReason::NotAWorktree => "not a git worktree",
881 SkipReason::NoRemote => "no remote to publish to",
882 SkipReason::DefaultBranchForcePush => {
883 "refusing to force-push the remote default branch; \
884 fast-forward it or open a PR instead"
885 }
886 }
887}
888
889async fn confirm_push(pending: usize, forced: usize) -> bool {
891 confirm_push_with(pending, forced, read_stdin_line()).await
892}
893
894async fn confirm_push_with(
898 pending: usize,
899 forced: usize,
900 read: impl std::future::Future<Output = Option<String>>,
901) -> bool {
902 use std::io::Write;
903 eprint!("{}", push_prompt(pending, forced));
904 let _ = std::io::stderr().flush();
905 read.await.as_deref().is_some_and(answer_is_yes)
906}
907
908fn push_prompt(pending: usize, forced: usize) -> String {
914 let noun = if pending == 1 { "branch" } else { "branches" };
915 if forced == 0 {
916 return format!("Push {pending} {noun}? [y/N] ");
917 }
918 let forced_noun = if forced == 1 { "one" } else { "them" };
919 format!(
920 "Push {pending} {noun}, force-pushing {forced} with a lease \
921 (this publishes rewritten history — anyone who has {forced_noun} \
922 will need to reset)? [y/N] "
923 )
924}
925
926#[derive(Parser)]
935pub struct MergeQueueCommand {
936 #[arg(value_name = "PATH", required = true)]
939 pub paths: Vec<PathBuf>,
940 #[arg(long)]
942 pub check: bool,
943 #[arg(short = 'y', long)]
945 pub yes: bool,
946 #[arg(long, value_name = "PATH")]
948 pub socket: Option<PathBuf>,
949}
950
951impl MergeQueueCommand {
952 pub async fn execute(self) -> Result<()> {
955 self.execute_with(confirm_enqueue).await
956 }
957
958 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
963 where
964 F: FnOnce(usize) -> Fut,
965 Fut: std::future::Future<Output = bool>,
966 {
967 let mut paths = Vec::with_capacity(self.paths.len());
970 for p in &self.paths {
971 let abs = std::fs::canonicalize(p)
972 .with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
973 paths.push(abs.to_string_lossy().to_string());
974 }
975 let socket = server::resolve_socket(self.socket)?;
976
977 let report = call(
979 &socket,
980 "merge-queue",
981 json!({ "paths": paths, "check": true }),
982 )
983 .await?;
984 println!("{}", render_eligibility_report(&report));
985
986 if self.check {
987 return Ok(());
988 }
989 let eligible = report
990 .get("eligible")
991 .and_then(Value::as_array)
992 .map_or(0, Vec::len);
993 if eligible == 0 {
994 println!("Nothing to enqueue.");
995 return Ok(());
996 }
997 if !self.yes && !confirm(eligible).await {
998 println!("Aborted; nothing was enqueued.");
999 return Ok(());
1000 }
1001
1002 let result = call(
1004 &socket,
1005 "merge-queue",
1006 json!({ "paths": paths, "confirmed": true }),
1007 )
1008 .await?;
1009 println!("{}", render_enqueue_result(&result));
1010 Ok(())
1011 }
1012}
1013
1014#[derive(Parser)]
1027pub struct RepositionCommand {
1028 #[arg(value_name = "PATH")]
1031 pub paths: Vec<PathBuf>,
1032 #[arg(
1035 long,
1036 value_name = "PATH",
1037 required_unless_present = "undo",
1038 conflicts_with = "undo"
1039 )]
1040 pub reference: Option<PathBuf>,
1041 #[arg(long, conflicts_with = "undo")]
1043 pub dry_run: bool,
1044 #[arg(long)]
1046 pub undo: bool,
1047 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1049 pub output: TableOrJson,
1050 #[arg(long, value_name = "PATH")]
1052 pub socket: Option<PathBuf>,
1053}
1054
1055impl RepositionCommand {
1056 pub async fn execute(self) -> Result<()> {
1058 let output = self.output;
1059 let socket = server::resolve_socket(self.socket)?;
1060 if self.undo {
1061 let reply = call(&socket, "reposition-undo", Value::Null).await?;
1062 return print_reposition(output, &reply);
1063 }
1064 let Some(reference) = self.reference.as_deref() else {
1066 bail!("`reposition` requires `--reference <PATH>`");
1067 };
1068
1069 let windows = call(&socket, "list", Value::Null).await?;
1073 let reference_key = window_key_for(&windows, reference, "repositioned")?;
1074 let mut target_keys = Vec::with_capacity(self.paths.len());
1075 for path in &self.paths {
1076 target_keys.push(window_key_for(&windows, path, "repositioned")?);
1077 }
1078
1079 let reply = call(
1080 &socket,
1081 "reposition",
1082 json!({
1083 "reference_key": reference_key,
1084 "target_keys": target_keys,
1085 "check": self.dry_run,
1086 }),
1087 )
1088 .await?;
1089 print_reposition(output, &reply)
1090 }
1091}
1092
1093fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
1095 match output {
1096 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
1097 TableOrJson::Table => println!("{}", render_reposition(reply)),
1098 }
1099 Ok(())
1100}
1101
1102#[derive(Parser)]
1117pub struct ReloadCommand {
1118 #[arg(value_name = "PATH", required = true)]
1122 pub paths: Vec<PathBuf>,
1123 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1125 pub output: TableOrJson,
1126 #[arg(long, value_name = "PATH")]
1128 pub socket: Option<PathBuf>,
1129}
1130
1131impl ReloadCommand {
1132 pub async fn execute(self) -> Result<()> {
1134 let socket = server::resolve_socket(self.socket)?;
1135 let windows = call(&socket, "list", Value::Null).await?;
1136 let mut target_keys = Vec::with_capacity(self.paths.len());
1137 for path in &self.paths {
1138 target_keys.push(window_key_for(&windows, path, "reloaded")?);
1139 }
1140
1141 let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
1142 match self.output {
1143 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
1144 TableOrJson::Table => println!("{}", render_reload(&reply)),
1145 }
1146 Ok(())
1147 }
1148}
1149
1150fn render_reload(reply: &Value) -> String {
1157 let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
1158 let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
1159 let unknown: Vec<String> = reply
1160 .get("unknown")
1161 .and_then(Value::as_array)
1162 .map(|keys| {
1163 keys.iter()
1164 .filter_map(Value::as_str)
1165 .map(sanitize)
1166 .collect()
1167 })
1168 .unwrap_or_default();
1169
1170 let noun = if requested == 1 { "window" } else { "windows" };
1173 let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
1174 if !unknown.is_empty() {
1175 out.push_str(&format!(
1178 "\nNo longer open, so not signalled: {}",
1179 unknown.join(", ")
1180 ));
1181 }
1182 out
1183}
1184
1185fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
1196 let wanted = std::fs::canonicalize(path)
1197 .with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
1198 windows
1199 .get("windows")
1200 .and_then(Value::as_array)
1201 .map(Vec::as_slice)
1202 .unwrap_or_default()
1203 .iter()
1204 .find(|window| {
1205 window
1206 .get("folders")
1207 .and_then(Value::as_array)
1208 .is_some_and(|folders| {
1209 folders.iter().filter_map(Value::as_str).any(|folder| {
1210 std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
1211 })
1212 })
1213 })
1214 .and_then(|window| window.get("key").and_then(Value::as_str))
1215 .map(ToString::to_string)
1216 .ok_or_else(|| {
1217 anyhow::anyhow!(
1218 "no VS Code window has {} open (only open windows can be {verb})",
1219 wanted.display()
1220 )
1221 })
1222}
1223
1224fn render_reposition(reply: &Value) -> String {
1228 if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
1229 return "omni-dev does not hold the macOS Accessibility permission, so no window \
1230 was touched.\nGrant it in System Settings → Privacy & Security → \
1231 Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
1232 .to_string();
1233 }
1234 if let Some(blocked) = reply.get("blocked") {
1235 let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
1236 let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
1237 return format!("Nothing was moved [{reason}]: {detail}");
1238 }
1239
1240 let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
1241 let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
1242 let mut out = String::new();
1243 if let Some(reference) = reply.get("reference") {
1244 let title = sanitize(
1245 reference
1246 .get("title")
1247 .and_then(Value::as_str)
1248 .unwrap_or("-"),
1249 );
1250 out.push_str(&format!(
1251 "Reference: {title} {}\n",
1252 render_frame(reference.get("frame"))
1253 ));
1254 }
1255 out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
1256 let results = reply
1257 .get("results")
1258 .and_then(Value::as_array)
1259 .map(Vec::as_slice)
1260 .unwrap_or_default();
1261 for result in results {
1262 let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
1263 let title = sanitize(
1264 result
1265 .get("title")
1266 .and_then(Value::as_str)
1267 .or_else(|| result.get("key").and_then(Value::as_str))
1268 .unwrap_or("-"),
1269 );
1270 let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
1271 out.push_str(&format!("\n {outcome}: {title} — {detail}"));
1272 }
1273 if results.is_empty() {
1274 out.push_str("\n (nothing to report)");
1275 }
1276 out
1277}
1278
1279fn render_frame(frame: Option<&Value>) -> String {
1281 let Some(frame) = frame else {
1282 return "-".to_string();
1283 };
1284 let field = |name: &str| {
1285 frame
1286 .get(name)
1287 .and_then(Value::as_f64)
1288 .unwrap_or(0.0)
1289 .round()
1290 };
1291 format!(
1292 "{}×{} at ({}, {})",
1293 field("width"),
1294 field("height"),
1295 field("x"),
1296 field("y")
1297 )
1298}
1299
1300#[derive(Parser)]
1306pub struct ShowClosedCommand {
1307 #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
1309 pub value: Option<bool>,
1310 #[arg(long, value_name = "PATH")]
1312 pub socket: Option<PathBuf>,
1313}
1314
1315impl ShowClosedCommand {
1316 pub async fn execute(self) -> Result<()> {
1318 let socket = server::resolve_socket(self.socket)?;
1319 if let Some(show_closed) = self.value {
1320 call(
1321 &socket,
1322 "set-show-closed",
1323 json!({ "show_closed": show_closed }),
1324 )
1325 .await?;
1326 println!("show-closed: {show_closed}");
1327 } else {
1328 let tree = call(&socket, "tree", Value::Null).await?;
1330 let current = tree
1331 .get("show_closed")
1332 .and_then(Value::as_bool)
1333 .unwrap_or(true);
1334 println!("show-closed: {current}");
1335 }
1336 Ok(())
1337 }
1338}
1339
1340#[derive(Parser)]
1346pub struct RegisterCommand {
1347 #[arg(long, value_name = "KEY")]
1349 pub key: String,
1350 #[arg(long = "folder", value_name = "PATH")]
1352 pub folders: Vec<PathBuf>,
1353 #[arg(long, value_name = "REPO")]
1361 pub repo_name: Option<String>,
1362 #[arg(long, value_name = "TITLE")]
1364 pub title: Option<String>,
1365 #[arg(long, value_name = "PID")]
1367 pub pid: Option<u32>,
1368 #[arg(long, value_name = "PATH")]
1370 pub socket: Option<PathBuf>,
1371}
1372
1373impl RegisterCommand {
1374 pub async fn execute(self) -> Result<()> {
1376 let socket = server::resolve_socket(self.socket)?;
1377 let payload = json!({
1378 "key": self.key,
1379 "folders": self.folders,
1380 "repo": self.repo_name,
1381 "title": self.title,
1382 "pid": self.pid,
1383 });
1384 call(&socket, "register", payload).await?;
1385 println!("Registered {}", self.key);
1386 Ok(())
1387 }
1388}
1389
1390#[derive(Parser)]
1397pub struct HeartbeatCommand {
1398 #[arg(long, value_name = "KEY")]
1400 pub key: String,
1401 #[arg(long, value_name = "PATH")]
1403 pub socket: Option<PathBuf>,
1404}
1405
1406impl HeartbeatCommand {
1407 pub async fn execute(self) -> Result<()> {
1409 let socket = server::resolve_socket(self.socket)?;
1410 let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
1411 let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
1412 let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
1415 let reload = reply
1416 .get("reload")
1417 .and_then(Value::as_bool)
1418 .unwrap_or(false);
1419 println!("known: {known}");
1420 println!("close: {close}");
1421 println!("reload: {reload}");
1422 Ok(())
1423 }
1424}
1425
1426#[derive(Parser)]
1429pub struct UnregisterCommand {
1430 #[arg(long, value_name = "KEY")]
1432 pub key: String,
1433 #[arg(long, value_name = "PATH")]
1435 pub socket: Option<PathBuf>,
1436}
1437
1438impl UnregisterCommand {
1439 pub async fn execute(self) -> Result<()> {
1441 let socket = server::resolve_socket(self.socket)?;
1442 let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
1443 let removed = reply
1444 .get("removed")
1445 .and_then(Value::as_bool)
1446 .unwrap_or(false);
1447 println!("removed: {removed}");
1448 Ok(())
1449 }
1450}
1451
1452fn render_safety_report(path: &Path, report: &Value) -> String {
1457 let removable = report
1458 .get("removable")
1459 .and_then(Value::as_bool)
1460 .unwrap_or(false);
1461 let is_main = report
1462 .get("is_main")
1463 .and_then(Value::as_bool)
1464 .unwrap_or(false);
1465 let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
1466 let mut out = format!("Worktree: {}", path.display());
1467 out.push_str(&format!("\n removable: {removable}"));
1468 out.push_str(&format!("\n main working tree: {is_main}"));
1469 if open {
1470 let key = sanitize(
1471 report
1472 .get("window_key")
1473 .and_then(Value::as_str)
1474 .unwrap_or("-"),
1475 );
1476 let count = report
1477 .get("window_folder_count")
1478 .and_then(Value::as_u64)
1479 .unwrap_or(0);
1480 out.push_str(&format!(
1481 "\n open in a window: yes (key {key}, {count} folder(s))"
1482 ));
1483 } else {
1484 out.push_str("\n open in a window: no");
1485 }
1486 out.push_str(&render_notes("risks", report.get("risks")));
1487 out.push_str(&render_notes("info", report.get("info")));
1488 out
1489}
1490
1491fn render_notes(label: &str, notes: Option<&Value>) -> String {
1494 let notes = notes
1495 .and_then(Value::as_array)
1496 .map(Vec::as_slice)
1497 .unwrap_or_default();
1498 if notes.is_empty() {
1499 return String::new();
1500 }
1501 let mut out = format!("\n {label}:");
1502 for note in notes {
1503 let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
1504 let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
1505 out.push_str(&format!("\n - [{kind}] {detail}"));
1506 }
1507 out
1508}
1509
1510fn render_eligibility_report(report: &Value) -> String {
1515 let eligible = report
1516 .get("eligible")
1517 .and_then(Value::as_array)
1518 .map(Vec::as_slice)
1519 .unwrap_or_default();
1520 let skipped = report
1521 .get("skipped")
1522 .and_then(Value::as_array)
1523 .map(Vec::as_slice)
1524 .unwrap_or_default();
1525 let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
1526 for pr in eligible {
1527 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1528 let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
1529 let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
1530 out.push_str(&format!("\n eligible: PR #{number} [{branch}] {path}"));
1531 }
1532 for skip in skipped {
1533 let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
1534 let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
1535 let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
1536 out.push_str(&format!("\n skipped [{kind}]: {path} — {detail}"));
1537 }
1538 out
1539}
1540
1541fn render_enqueue_result(result: &Value) -> String {
1546 let queued = result
1547 .get("queued")
1548 .and_then(Value::as_array)
1549 .map(Vec::as_slice)
1550 .unwrap_or_default();
1551 let failed = result
1552 .get("failed")
1553 .and_then(Value::as_array)
1554 .map(Vec::as_slice)
1555 .unwrap_or_default();
1556 let skipped = result
1557 .get("skipped")
1558 .and_then(Value::as_array)
1559 .map_or(0, Vec::len);
1560 let mut out = format!(
1561 "Queued: {} / Failed: {} / Skipped: {}",
1562 queued.len(),
1563 failed.len(),
1564 skipped
1565 );
1566 for pr in queued {
1567 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1568 let already = pr
1569 .get("already_queued")
1570 .and_then(Value::as_bool)
1571 .unwrap_or(false);
1572 let suffix = if already { " (already queued)" } else { "" };
1573 out.push_str(&format!("\n queued: PR #{number}{suffix}"));
1574 }
1575 for pr in failed {
1576 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1577 let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
1578 out.push_str(&format!("\n failed: PR #{number} — {error}"));
1579 }
1580 out
1581}
1582
1583async fn confirm_removal(has_risks: bool) -> bool {
1590 confirm_removal_with(has_risks, read_stdin_line()).await
1591}
1592
1593async fn confirm_removal_with(
1598 has_risks: bool,
1599 read: impl std::future::Future<Output = Option<String>>,
1600) -> bool {
1601 use std::io::Write;
1602 eprint!("{}", confirm_prompt(has_risks));
1603 let _ = std::io::stderr().flush();
1604 read.await.as_deref().is_some_and(answer_is_yes)
1605}
1606
1607async fn confirm_enqueue(count: usize) -> bool {
1611 confirm_enqueue_with(count, read_stdin_line()).await
1612}
1613
1614async fn confirm_enqueue_with(
1618 count: usize,
1619 read: impl std::future::Future<Output = Option<String>>,
1620) -> bool {
1621 use std::io::Write;
1622 eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
1623 let _ = std::io::stderr().flush();
1624 read.await.as_deref().is_some_and(answer_is_yes)
1625}
1626
1627async fn read_stdin_line() -> Option<String> {
1631 tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
1632 .await
1633 .ok()
1634 .flatten()
1635}
1636
1637fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
1642 let mut answer = String::new();
1643 reader.read_line(&mut answer).ok().map(|_| answer)
1644}
1645
1646fn confirm_prompt(has_risks: bool) -> &'static str {
1649 if has_risks {
1650 "Delete this worktree despite the risks above? [y/N] "
1651 } else {
1652 "Delete this worktree? [y/N] "
1653 }
1654}
1655
1656fn answer_is_yes(answer: &str) -> bool {
1659 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
1660}
1661
1662async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
1669 let paths = worktree_paths(result);
1670 if paths.is_empty() {
1671 return;
1672 }
1673 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
1674 return;
1675 };
1676 if let Some(results) = reply.get("results").and_then(Value::as_object) {
1677 merge_ahead_behind(result, results);
1678 }
1679}
1680
1681fn worktree_paths(result: &Value) -> Vec<String> {
1684 let mut paths = Vec::new();
1685 for repo in result
1686 .get("repos")
1687 .and_then(Value::as_array)
1688 .map(Vec::as_slice)
1689 .unwrap_or_default()
1690 {
1691 for worktree in repo
1692 .get("worktrees")
1693 .and_then(Value::as_array)
1694 .map(Vec::as_slice)
1695 .unwrap_or_default()
1696 {
1697 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
1698 paths.push(path.to_string());
1699 }
1700 }
1701 }
1702 paths
1703}
1704
1705fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
1710 for repo in result
1711 .get_mut("repos")
1712 .and_then(Value::as_array_mut)
1713 .into_iter()
1714 .flatten()
1715 {
1716 for worktree in repo
1717 .get_mut("worktrees")
1718 .and_then(Value::as_array_mut)
1719 .into_iter()
1720 .flatten()
1721 {
1722 let Some(obj) = worktree.as_object_mut() else {
1726 continue;
1727 };
1728 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
1729 continue;
1730 };
1731 let Some(counts) = results.get(&path) else {
1732 continue;
1733 };
1734 if let (Some(ahead), Some(behind)) =
1737 (counts.get("ahead").cloned(), counts.get("behind").cloned())
1738 {
1739 obj.insert("ahead".to_string(), ahead);
1740 obj.insert("behind".to_string(), behind);
1741 }
1742 if let Some(main_behind) = counts.get("main_behind").cloned() {
1747 obj.insert("main_behind".to_string(), main_behind);
1748 }
1749 }
1750 }
1751}
1752
1753async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1756 let reply = DaemonClient::new(socket)
1757 .request(DaemonEnvelope::service(SERVICE, op, payload))
1758 .await?;
1759 reply_payload(reply)
1760}
1761
1762fn reply_payload(reply: DaemonReply) -> Result<Value> {
1765 if reply.ok {
1766 Ok(reply.payload)
1767 } else {
1768 bail!(
1769 "daemon returned an error: {}",
1770 reply.error.as_deref().unwrap_or("unknown error")
1771 )
1772 }
1773}
1774
1775fn render_windows(result: &Value) -> String {
1780 let windows = result
1781 .get("windows")
1782 .and_then(Value::as_array)
1783 .map(Vec::as_slice)
1784 .unwrap_or_default();
1785 if windows.is_empty() {
1786 return "No open windows.".to_string();
1787 }
1788 let mut out = format!(
1789 "{:<22} {:<24} {:<9} {:<40} {:>5}",
1790 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
1791 );
1792 for window in windows {
1793 let repo = sanitize(repo_name(window));
1794 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
1795 let sync = sync_summary(window);
1796 let folder_disp = folder_summary(window);
1797 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
1798 out.push_str(&format!(
1799 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
1800 ));
1801 }
1802 out
1803}
1804
1805fn render_tree(result: &Value) -> String {
1811 let repos = result
1812 .get("repos")
1813 .and_then(Value::as_array)
1814 .map(Vec::as_slice)
1815 .unwrap_or_default();
1816 if repos.is_empty() {
1817 return "No repositories open.".to_string();
1818 }
1819 let mut out = String::new();
1820 for (i, repo) in repos.iter().enumerate() {
1821 if i > 0 {
1824 out.push_str("\n\n");
1825 }
1826 out.push_str(&repo_header(repo));
1827 for worktree in repo
1828 .get("worktrees")
1829 .and_then(Value::as_array)
1830 .map(Vec::as_slice)
1831 .unwrap_or_default()
1832 {
1833 out.push('\n');
1834 out.push_str(&worktree_row(worktree));
1835 }
1836 }
1837 out
1838}
1839
1840fn repo_header(repo: &Value) -> String {
1843 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
1844 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
1845 match github_summary(repo) {
1846 Some(github) => format!("{name} ({github}) {root}"),
1847 None => format!("{name} {root}"),
1848 }
1849}
1850
1851fn github_summary(repo: &Value) -> Option<String> {
1854 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
1855 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
1856 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
1857}
1858
1859fn worktree_row(worktree: &Value) -> String {
1863 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
1864 '*'
1865 } else {
1866 ' '
1867 };
1868 let branch = sanitize(
1869 worktree
1870 .get("branch")
1871 .and_then(Value::as_str)
1872 .unwrap_or("-"),
1873 );
1874 let sync = sync_summary(worktree);
1875 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
1876 "open"
1877 } else {
1878 ""
1879 };
1880 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
1881 format!(" {marker} {branch:<24} {sync:<16} {open:<5} {path}")
1882}
1883
1884fn repo_name(window: &Value) -> &str {
1888 window
1889 .get("main_repo")
1890 .and_then(Value::as_str)
1891 .or_else(|| window.get("repo").and_then(Value::as_str))
1892 .unwrap_or("-")
1893}
1894
1895fn sync_summary(window: &Value) -> String {
1902 let ahead = window.get("ahead").and_then(Value::as_u64);
1903 let behind = window.get("behind").and_then(Value::as_u64);
1904 let base = match (ahead, behind) {
1905 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
1906 _ => "-".to_string(),
1907 };
1908 match window.get("main_behind").and_then(Value::as_u64) {
1909 Some(main_behind) => format!("{base} main-{main_behind}"),
1910 None => base,
1911 }
1912}
1913
1914fn folder_summary(window: &Value) -> String {
1917 let folders = window
1918 .get("folders")
1919 .and_then(Value::as_array)
1920 .map(Vec::as_slice)
1921 .unwrap_or_default();
1922 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
1923 let extra = folders.len().saturating_sub(1);
1924 if extra > 0 {
1925 format!("{first} (+{extra})")
1926 } else {
1927 first
1928 }
1929}
1930
1931fn sanitize(s: &str) -> String {
1935 sanitize_for_terminal(s)
1936}
1937
1938fn age_secs(ts: Option<&str>) -> i64 {
1940 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1941 .map_or(0, |t| {
1942 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1943 })
1944}
1945
1946#[cfg(test)]
1947#[allow(clippy::unwrap_used, clippy::expect_used)]
1948mod tests {
1949 use super::*;
1950 use serde_json::json;
1951
1952 #[derive(Parser)]
1954 struct Wrapper {
1955 #[command(subcommand)]
1956 cmd: WorktreesSubcommands,
1957 }
1958
1959 fn parse(args: &[&str]) -> WorktreesSubcommands {
1960 let mut full = vec!["omni-dev"];
1961 full.extend_from_slice(args);
1962 Wrapper::try_parse_from(full).unwrap().cmd
1963 }
1964
1965 #[test]
1966 fn list_parses_flags_and_defaults() {
1967 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
1969 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
1971 assert_eq!(cmd.output, TableOrJson::Table);
1972 assert!(!cmd.json);
1973 assert!(cmd.socket.is_none());
1974
1975 let cmd =
1976 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1977 assert_eq!(cmd.output, TableOrJson::Json);
1978 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1979 }
1980
1981 #[test]
1982 fn list_deprecated_json_flag_still_parses() {
1983 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
1985 assert!(cmd.json);
1986 assert_eq!(cmd.output, TableOrJson::Table);
1987 }
1988
1989 #[test]
1990 fn tree_parses_flags_and_defaults() {
1991 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
1993 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1994 assert_eq!(cmd.output, TableOrJson::Table);
1995 assert!(cmd.socket.is_none());
1996
1997 let cmd =
1998 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1999 assert_eq!(cmd.output, TableOrJson::Json);
2000 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2001 }
2002
2003 #[test]
2004 fn focus_parses_path_and_socket() {
2005 assert!(matches!(
2007 parse(&["focus", "/home/me/wt"]),
2008 WorktreesSubcommands::Focus(_)
2009 ));
2010 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
2012 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2013 assert!(cmd.socket.is_none());
2014
2015 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
2016 .unwrap();
2017 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2018
2019 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
2021 }
2022
2023 #[tokio::test]
2024 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
2025 let cmd = FocusCommand {
2028 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
2029 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
2030 };
2031 let err = cmd.execute().await.unwrap_err();
2032 assert!(
2033 err.to_string().contains("cannot resolve worktree path"),
2034 "{err}"
2035 );
2036 }
2037
2038 #[tokio::test]
2039 async fn focus_sends_the_open_op_for_an_existing_folder() {
2040 let (_dir, sock, server) =
2044 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
2045 let target = tempfile::tempdir().unwrap();
2046 let cmd = WorktreesCommand {
2047 command: WorktreesSubcommands::Focus(FocusCommand {
2048 path: target.path().to_path_buf(),
2049 socket: Some(sock),
2050 }),
2051 };
2052 cmd.execute(None).await.unwrap();
2053 server.await.unwrap();
2054 }
2055
2056 #[test]
2057 fn render_windows_handles_empty_replies() {
2058 assert_eq!(
2059 render_windows(&json!({ "windows": [] })),
2060 "No open windows."
2061 );
2062 assert_eq!(render_windows(&json!({})), "No open windows.");
2063 }
2064
2065 #[test]
2066 fn render_windows_renders_rows() {
2067 let result = json!({ "windows": [{
2068 "key": "w1",
2069 "repo": "omni-dev",
2070 "branch": "issue-1011",
2071 "ahead": 2,
2072 "behind": 1,
2073 "folders": ["/home/me/omni-dev", "/home/me/docs"],
2074 "last_seen": "2000-01-01T00:00:00Z",
2075 }]});
2076 let table = render_windows(&result);
2077 assert!(table.contains("omni-dev"), "{table}");
2078 assert!(table.contains("issue-1011"), "{table}");
2080 assert!(table.contains("+2 -1"), "{table}");
2081 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
2083 assert_eq!(table.lines().count(), 2, "{table}");
2085 }
2086
2087 #[test]
2088 fn render_windows_prefers_main_repo_over_companion_repo() {
2089 let result = json!({ "windows": [{
2093 "key": "w1",
2094 "repo": "issue-1250",
2095 "main_repo": "omni-dev",
2096 "branch": "issue-1250",
2097 "folders": ["/home/me/worktrees/issue-1250"],
2098 "last_seen": "2000-01-01T00:00:00Z",
2099 }]});
2100 let table = render_windows(&result);
2101 assert!(table.contains("omni-dev"), "{table}");
2102 let data_row = table.lines().nth(1).unwrap();
2105 assert!(data_row.starts_with("omni-dev"), "{data_row}");
2106 }
2107
2108 #[test]
2109 fn repo_name_falls_back_to_companion_repo_then_dash() {
2110 assert_eq!(
2111 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
2112 "omni-dev"
2113 );
2114 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
2115 assert_eq!(repo_name(&json!({})), "-");
2116 }
2117
2118 #[test]
2119 fn render_windows_strips_control_bytes() {
2120 let result = json!({ "windows": [{
2123 "key": "w1",
2124 "repo": "evil\x1b[31mrepo",
2125 "branch": "br\ranch\x07\u{9b}2J",
2126 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
2127 "last_seen": "2000-01-01T00:00:00Z",
2128 }]});
2129 let table = render_windows(&result);
2130 assert!(
2131 !table.contains(|c: char| c.is_control() && c != '\n'),
2132 "{table:?}"
2133 );
2134 assert!(table.contains("evil[31mrepo"), "{table:?}");
2136 assert!(table.contains("branch2J"), "{table:?}");
2137 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
2138 assert_eq!(table.lines().count(), 2, "{table:?}");
2140 }
2141
2142 #[test]
2143 fn sync_summary_formats_or_dashes() {
2144 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2145 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
2146 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
2148 assert_eq!(sync_summary(&json!({})), "-");
2149 }
2150
2151 #[test]
2152 fn sync_summary_appends_main_behind_when_present() {
2153 assert_eq!(
2156 sync_summary(&json!({ "ahead": 2, "behind": 1, "main_behind": 5 })),
2157 "+2 -1 main-5"
2158 );
2159 assert_eq!(sync_summary(&json!({ "main_behind": 7 })), "- main-7");
2160 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2163 }
2164
2165 #[test]
2166 fn folder_summary_strips_control_bytes() {
2167 assert_eq!(
2168 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
2169 "/a[2J/b"
2170 );
2171 }
2172
2173 #[test]
2174 fn folder_summary_counts_extra_folders() {
2175 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
2176 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
2177 assert_eq!(
2178 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
2179 "/a (+2)"
2180 );
2181 }
2182
2183 #[test]
2184 fn age_secs_handles_absent_and_unparseable_and_past() {
2185 assert_eq!(age_secs(None), 0);
2186 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
2187 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
2188 }
2189
2190 #[test]
2191 fn render_tree_handles_empty_replies() {
2192 assert_eq!(
2193 render_tree(&json!({ "repos": [] })),
2194 "No repositories open."
2195 );
2196 assert_eq!(render_tree(&json!({})), "No repositories open.");
2197 }
2198
2199 #[test]
2200 fn worktree_paths_collects_every_worktree_in_render_order() {
2201 let result = json!({ "repos": [
2202 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
2204 { "worktrees": [ { "path": "/c" } ] },
2205 ]});
2206 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
2207 assert!(worktree_paths(&json!({})).is_empty());
2209 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
2210 }
2211
2212 #[test]
2213 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
2214 let mut result = json!({ "repos": [{ "worktrees": [
2218 { "path": "/a", "branch": "main" },
2219 { "path": "/b", "branch": "feature" },
2220 ]}]});
2221 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
2222 merge_ahead_behind(&mut result, results.as_object().unwrap());
2223
2224 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2225 let a = &worktrees[0];
2226 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
2227 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2228 assert_eq!(sync_summary(a), "+2 -1");
2230 let b = &worktrees[1];
2231 assert!(b.get("ahead").is_none(), "{b:?}");
2232 assert!(b.get("behind").is_none(), "{b:?}");
2233 assert_eq!(sync_summary(b), "-");
2234 }
2235
2236 #[test]
2237 fn merge_ahead_behind_folds_main_behind_independently_of_ahead_behind() {
2238 let mut result = json!({ "repos": [{ "worktrees": [
2242 { "path": "/a", "branch": "feature" },
2243 { "path": "/b", "branch": "no-upstream" },
2244 { "path": "/c", "branch": "main" },
2245 ]}]});
2246 let results = json!({
2247 "/a": { "ahead": 1, "behind": 1, "main_behind": 3 },
2248 "/b": { "main_behind": 7 },
2249 "/c": { "ahead": 1, "behind": 1 },
2250 });
2251 merge_ahead_behind(&mut result, results.as_object().unwrap());
2252
2253 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2254 let a = &worktrees[0];
2255 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(1));
2256 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2257 assert_eq!(a.get("main_behind").and_then(Value::as_u64), Some(3));
2258
2259 let b = &worktrees[1];
2260 assert!(b.get("ahead").is_none(), "{b:?}");
2261 assert!(b.get("behind").is_none(), "{b:?}");
2262 assert_eq!(b.get("main_behind").and_then(Value::as_u64), Some(7));
2263
2264 let c = &worktrees[2];
2265 assert_eq!(c.get("ahead").and_then(Value::as_u64), Some(1));
2266 assert_eq!(c.get("behind").and_then(Value::as_u64), Some(1));
2267 assert!(c.get("main_behind").is_none(), "{c:?}");
2268 }
2269
2270 #[test]
2271 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
2272 let mut result = json!({ "repos": [{ "worktrees": [
2276 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
2280 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
2282
2283 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2284 assert_eq!(worktrees[0], json!("not-an-object"));
2286 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
2288 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
2290 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
2291 }
2292
2293 #[tokio::test]
2294 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
2295 let mut result = json!({ "repos": [] });
2298 let before = result.clone();
2299 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2300 assert_eq!(result, before);
2301 }
2302
2303 #[tokio::test]
2304 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
2305 let mut result =
2308 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2309 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2310 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2311 assert!(wt.get("ahead").is_none(), "{wt:?}");
2312 assert!(wt.get("behind").is_none(), "{wt:?}");
2313 }
2314
2315 fn fake_daemon_reply(
2320 reply: Value,
2321 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2322 use futures::{SinkExt, StreamExt};
2323 use tokio::net::UnixListener;
2324 use tokio_util::codec::{Framed, LinesCodec};
2325
2326 let dir = tempfile::tempdir_in("/tmp").unwrap();
2328 let sock = dir.path().join("d.sock");
2329 let listener = UnixListener::bind(&sock).unwrap();
2330 let server = tokio::spawn(async move {
2331 let (stream, _) = listener.accept().await.unwrap();
2332 let mut framed = Framed::new(stream, LinesCodec::new());
2333 let _req = framed.next().await.unwrap().unwrap();
2334 framed
2335 .send(serde_json::to_string(&reply).unwrap())
2336 .await
2337 .unwrap();
2338 });
2339 (dir, sock, server)
2340 }
2341
2342 fn fake_daemon_replies(
2346 replies: Vec<Value>,
2347 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2348 use futures::{SinkExt, StreamExt};
2349 use tokio::net::UnixListener;
2350 use tokio_util::codec::{Framed, LinesCodec};
2351
2352 let dir = tempfile::tempdir_in("/tmp").unwrap();
2353 let sock = dir.path().join("d.sock");
2354 let listener = UnixListener::bind(&sock).unwrap();
2355 let server = tokio::spawn(async move {
2356 for reply in replies {
2357 let (stream, _) = listener.accept().await.unwrap();
2358 let mut framed = Framed::new(stream, LinesCodec::new());
2359 let _req = framed.next().await.unwrap().unwrap();
2360 framed
2361 .send(serde_json::to_string(&reply).unwrap())
2362 .await
2363 .unwrap();
2364 }
2365 });
2366 (dir, sock, server)
2367 }
2368
2369 #[tokio::test]
2370 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
2371 let (_dir, sock, server) = fake_daemon_reply(
2372 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
2373 );
2374 let mut result =
2375 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2376 enrich_ahead_behind(&sock, &mut result).await;
2377 server.await.unwrap();
2378
2379 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2380 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
2381 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
2382 }
2383
2384 #[tokio::test]
2385 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
2386 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
2389 let mut result =
2390 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2391 enrich_ahead_behind(&sock, &mut result).await;
2392 server.await.unwrap();
2393
2394 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2395 assert!(wt.get("ahead").is_none(), "{wt:?}");
2396 assert!(wt.get("behind").is_none(), "{wt:?}");
2397 }
2398
2399 #[test]
2400 fn render_tree_groups_repos_and_worktrees() {
2401 let result = json!({ "repos": [{
2402 "main_repo": "omni-dev",
2403 "github": { "owner": "rust-works", "name": "omni-dev" },
2404 "root": "/home/me/omni-dev",
2405 "worktrees": [
2406 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
2407 "is_main": true, "open": true, "window_key": "w1" },
2408 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
2409 "is_main": false, "open": false },
2410 ],
2411 }]});
2412 let out = render_tree(&result);
2413 let header = out.lines().next().unwrap();
2415 assert!(header.contains("omni-dev"), "{out}");
2416 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
2417 assert!(header.contains("/home/me/omni-dev"), "{out}");
2418 assert!(
2420 out.lines()
2421 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
2422 "{out}"
2423 );
2424 let linked = out
2426 .lines()
2427 .find(|l| l.contains("issue-1300"))
2428 .unwrap_or_default();
2429 assert!(!linked.contains('*'), "{linked}");
2430 assert!(!linked.contains("open"), "{linked}");
2431 assert!(linked.contains("+1 -3"), "{linked}");
2432 assert_eq!(out.lines().count(), 3, "{out}");
2434 }
2435
2436 #[test]
2437 fn render_tree_separates_multiple_repos_with_blank_line() {
2438 let result = json!({ "repos": [
2439 {
2440 "main_repo": "alpha",
2441 "root": "/r/alpha",
2442 "worktrees": [
2443 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
2444 ],
2445 },
2446 {
2447 "main_repo": "beta",
2448 "root": "/r/beta",
2449 "worktrees": [
2450 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
2451 ],
2452 },
2453 ]});
2454 let out = render_tree(&result);
2455 assert!(
2457 out.contains("\n\nbeta"),
2458 "repos not blank-separated: {out:?}"
2459 );
2460 let alpha = out.find("alpha").unwrap();
2461 let beta = out.find("beta").unwrap();
2462 assert!(alpha < beta, "repo order not preserved: {out}");
2463 assert_eq!(out.lines().count(), 5, "{out:?}");
2464 }
2465
2466 #[test]
2467 fn render_tree_omits_github_for_non_github_repo() {
2468 let result = json!({ "repos": [{
2469 "main_repo": "internal",
2470 "root": "/srv/internal",
2471 "worktrees": [
2472 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
2473 ],
2474 }]});
2475 let out = render_tree(&result);
2476 assert!(!out.contains("github:"), "{out}");
2477 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
2478 }
2479
2480 #[test]
2481 fn render_tree_strips_control_bytes() {
2482 let result = json!({ "repos": [{
2485 "main_repo": "evil\x1b[31mrepo",
2486 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
2487 "root": "/tmp/r\x1b]0;x\x07oot",
2488 "worktrees": [
2489 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
2490 ],
2491 }]});
2492 let out = render_tree(&result);
2493 assert!(
2494 !out.contains(|c: char| c.is_control() && c != '\n'),
2495 "{out:?}"
2496 );
2497 assert_eq!(out.lines().count(), 2, "{out:?}");
2499 }
2500
2501 #[test]
2502 fn github_summary_needs_both_owner_and_name() {
2503 assert_eq!(
2504 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
2505 Some("github: o/n")
2506 );
2507 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
2508 assert_eq!(github_summary(&json!({})), None);
2509 }
2510
2511 #[test]
2512 fn reply_payload_unwraps_ok_and_maps_errors() {
2513 assert_eq!(
2515 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2516 json!({ "a": 1 })
2517 );
2518 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2520 assert!(err.to_string().contains("boom"), "{err}");
2521 let err = reply_payload(DaemonReply {
2523 ok: false,
2524 payload: Value::Null,
2525 error: None,
2526 })
2527 .unwrap_err();
2528 assert!(err.to_string().contains("unknown error"), "{err}");
2529 }
2530
2531 #[test]
2534 fn new_subcommands_route_and_require_their_args() {
2535 assert!(matches!(
2536 parse(&["close", "/home/me/wt"]),
2537 WorktreesSubcommands::Close(_)
2538 ));
2539 assert!(matches!(
2540 parse(&["show-closed"]),
2541 WorktreesSubcommands::ShowClosed(_)
2542 ));
2543 assert!(matches!(
2544 parse(&["register", "--key", "w1"]),
2545 WorktreesSubcommands::Register(_)
2546 ));
2547 assert!(matches!(
2548 parse(&["heartbeat", "--key", "w1"]),
2549 WorktreesSubcommands::Heartbeat(_)
2550 ));
2551 assert!(matches!(
2552 parse(&["unregister", "--key", "w1"]),
2553 WorktreesSubcommands::Unregister(_)
2554 ));
2555
2556 assert!(CloseCommand::try_parse_from(["close"]).is_err());
2558 assert!(RegisterCommand::try_parse_from(["register"]).is_err());
2559 assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
2560 assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
2561 }
2562
2563 #[test]
2564 fn close_parses_flags() {
2565 let cmd = CloseCommand::try_parse_from([
2566 "close",
2567 "/home/me/wt",
2568 "--window-only",
2569 "--dry-run",
2570 "-y",
2571 "--socket",
2572 "/tmp/d.sock",
2573 ])
2574 .unwrap();
2575 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2576 assert!(cmd.window_only && cmd.dry_run && cmd.yes);
2577 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2578
2579 let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
2581 assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
2582 }
2583
2584 #[test]
2585 fn tree_follow_flag_parses() {
2586 let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
2587 assert!(cmd.follow);
2588 let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
2589 assert!(cmd.follow);
2590 assert_eq!(cmd.output, TableOrJson::Json);
2591 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2592 assert!(!cmd.follow);
2593 }
2594
2595 #[test]
2596 fn show_closed_parses_optional_bool() {
2597 assert!(ShowClosedCommand::try_parse_from(["show-closed"])
2598 .unwrap()
2599 .value
2600 .is_none());
2601 assert_eq!(
2602 ShowClosedCommand::try_parse_from(["show-closed", "false"])
2603 .unwrap()
2604 .value,
2605 Some(false)
2606 );
2607 assert_eq!(
2608 ShowClosedCommand::try_parse_from(["show-closed", "true"])
2609 .unwrap()
2610 .value,
2611 Some(true)
2612 );
2613 assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
2615 }
2616
2617 #[test]
2618 fn register_collects_repeated_folders() {
2619 let cmd = RegisterCommand::try_parse_from([
2620 "register",
2621 "--key",
2622 "w1",
2623 "--folder",
2624 "/a",
2625 "--folder",
2626 "/b",
2627 "--repo-name",
2628 "r",
2629 "--pid",
2630 "42",
2631 ])
2632 .unwrap();
2633 assert_eq!(cmd.key, "w1");
2634 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
2635 assert_eq!(cmd.repo_name.as_deref(), Some("r"));
2636 assert_eq!(cmd.pid, Some(42));
2637 }
2638
2639 #[test]
2640 fn answer_is_yes_accepts_only_affirmatives() {
2641 for yes in ["y", "Y", "yes", "YES", " yes \n"] {
2642 assert!(answer_is_yes(yes), "{yes:?}");
2643 }
2644 for no in ["", "n", "no", "nope", "true", "\n"] {
2645 assert!(!answer_is_yes(no), "{no:?}");
2646 }
2647 }
2648
2649 #[test]
2650 fn confirm_prompt_mentions_risks_only_when_present() {
2651 assert!(confirm_prompt(true).contains("risks"));
2655 assert!(!confirm_prompt(false).contains("risks"));
2656 assert!(confirm_prompt(true).contains("[y/N]"));
2657 assert!(confirm_prompt(false).contains("[y/N]"));
2658 }
2659
2660 #[test]
2661 fn read_line_from_maps_input_and_eof() {
2662 use std::io::Cursor;
2663 assert_eq!(
2667 read_line_from(&mut Cursor::new("y\n")).as_deref(),
2668 Some("y\n")
2669 );
2670 assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
2671 assert_eq!(
2672 read_line_from(&mut Cursor::new("no-newline")).as_deref(),
2673 Some("no-newline")
2674 );
2675 }
2676
2677 #[test]
2678 fn render_safety_report_renders_fields_and_notes() {
2679 let report = json!({
2680 "removable": true,
2681 "is_main": false,
2682 "open": true,
2683 "window_key": "w1",
2684 "window_folder_count": 2,
2685 "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
2686 "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
2687 });
2688 let out = render_safety_report(Path::new("/home/me/wt"), &report);
2689 assert!(out.contains("/home/me/wt"), "{out}");
2690 assert!(out.contains("removable: true"), "{out}");
2691 assert!(
2692 out.contains("open in a window: yes (key w1, 2 folder(s))"),
2693 "{out}"
2694 );
2695 assert!(out.contains("[dirty] uncommitted changes"), "{out}");
2696 assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
2697 }
2698
2699 #[test]
2700 fn render_safety_report_handles_no_window_and_no_notes() {
2701 let report = json!({ "removable": false, "is_main": true, "open": false });
2702 let out = render_safety_report(Path::new("/r"), &report);
2703 assert!(out.contains("removable: false"), "{out}");
2704 assert!(out.contains("main working tree: true"), "{out}");
2705 assert!(out.contains("open in a window: no"), "{out}");
2706 assert!(!out.contains("risks:"), "{out}");
2708 assert!(!out.contains("info:"), "{out}");
2709 }
2710
2711 #[test]
2712 fn render_safety_report_strips_control_bytes() {
2713 let report = json!({
2716 "removable": true, "is_main": false, "open": true,
2717 "window_key": "w\x1b[31m1", "window_folder_count": 1,
2718 "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
2719 "info": [],
2720 });
2721 let out = render_safety_report(Path::new("/r"), &report);
2722 assert!(
2723 !out.contains(|c: char| c.is_control() && c != '\n'),
2724 "{out:?}"
2725 );
2726 }
2727
2728 fn fake_daemon_seq(
2734 replies: Vec<Value>,
2735 ) -> (
2736 tempfile::TempDir,
2737 PathBuf,
2738 tokio::task::JoinHandle<Vec<Value>>,
2739 ) {
2740 use futures::{SinkExt, StreamExt};
2741 use tokio::net::UnixListener;
2742 use tokio_util::codec::{Framed, LinesCodec};
2743
2744 let dir = tempfile::tempdir_in("/tmp").unwrap();
2745 let sock = dir.path().join("d.sock");
2746 let listener = UnixListener::bind(&sock).unwrap();
2747 let server = tokio::spawn(async move {
2748 let mut requests = Vec::new();
2749 for reply in replies {
2750 let (stream, _) = listener.accept().await.unwrap();
2751 let mut framed = Framed::new(stream, LinesCodec::new());
2752 let req = framed.next().await.unwrap().unwrap();
2753 requests.push(serde_json::from_str::<Value>(&req).unwrap());
2754 framed
2755 .send(serde_json::to_string(&reply).unwrap())
2756 .await
2757 .unwrap();
2758 }
2759 requests
2760 });
2761 (dir, sock, server)
2762 }
2763
2764 #[tokio::test]
2765 async fn close_window_only_sends_remove_false() {
2766 let (_dir, sock, server) =
2767 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
2768 let target = tempfile::tempdir().unwrap();
2769 CloseCommand {
2770 path: target.path().to_path_buf(),
2771 window_only: true,
2772 dry_run: false,
2773 yes: false,
2774 socket: Some(sock),
2775 }
2776 .execute()
2777 .await
2778 .unwrap();
2779 let reqs = server.await.unwrap();
2780 assert_eq!(reqs.len(), 1);
2783 assert_eq!(reqs[0]["op"], "close");
2784 assert_eq!(reqs[0]["payload"]["remove"], json!(false));
2785 assert!(
2786 reqs[0]["payload"].get("confirmed").is_none(),
2787 "{:?}",
2788 reqs[0]
2789 );
2790 let want = std::fs::canonicalize(target.path()).unwrap();
2792 assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
2793 }
2794
2795 #[tokio::test]
2796 async fn close_window_only_dry_run_never_contacts_the_daemon() {
2797 let target = tempfile::tempdir().unwrap();
2800 CloseCommand {
2801 path: target.path().to_path_buf(),
2802 window_only: true,
2803 dry_run: true,
2804 yes: false,
2805 socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
2806 }
2807 .execute()
2808 .await
2809 .unwrap();
2810 }
2811
2812 #[tokio::test]
2813 async fn close_dry_run_only_runs_phase_one() {
2814 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2816 "ok": true,
2817 "payload": { "removable": true, "is_main": false, "open": false,
2818 "window_folder_count": 0, "risks": [], "info": [] }
2819 })]);
2820 let target = tempfile::tempdir().unwrap();
2821 CloseCommand {
2822 path: target.path().to_path_buf(),
2823 window_only: false,
2824 dry_run: true,
2825 yes: false,
2826 socket: Some(sock),
2827 }
2828 .execute()
2829 .await
2830 .unwrap();
2831 let reqs = server.await.unwrap();
2832 assert_eq!(reqs.len(), 1);
2834 assert_eq!(reqs[0]["op"], "close");
2835 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2836 assert!(
2837 reqs[0]["payload"].get("confirmed").is_none(),
2838 "{:?}",
2839 reqs[0]
2840 );
2841 }
2842
2843 #[tokio::test]
2844 async fn close_yes_executes_phase_two() {
2845 let (_dir, sock, server) = fake_daemon_seq(vec![
2847 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2848 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2849 json!({ "ok": true, "payload": { "removed": true } }),
2850 ]);
2851 let target = tempfile::tempdir().unwrap();
2852 CloseCommand {
2853 path: target.path().to_path_buf(),
2854 window_only: false,
2855 dry_run: false,
2856 yes: true,
2857 socket: Some(sock),
2858 }
2859 .execute()
2860 .await
2861 .unwrap();
2862 let reqs = server.await.unwrap();
2863 assert_eq!(reqs.len(), 2);
2865 assert_eq!(reqs[0]["op"], "close");
2866 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2867 assert!(
2868 reqs[0]["payload"].get("confirmed").is_none(),
2869 "{:?}",
2870 reqs[0]
2871 );
2872 assert_eq!(reqs[1]["op"], "close");
2873 assert_eq!(reqs[1]["payload"]["remove"], json!(true));
2874 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2875 assert!(
2877 reqs[1]["payload"].get("requester_key").is_none(),
2878 "{:?}",
2879 reqs[1]
2880 );
2881 }
2882
2883 #[tokio::test]
2884 async fn close_refuses_a_non_removable_target() {
2885 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2888 "ok": true,
2889 "payload": { "removable": false, "is_main": true, "open": false,
2890 "window_folder_count": 0, "risks": [], "info": [] }
2891 })]);
2892 let target = tempfile::tempdir().unwrap();
2893 let err = CloseCommand {
2894 path: target.path().to_path_buf(),
2895 window_only: false,
2896 dry_run: false,
2897 yes: true,
2898 socket: Some(sock),
2899 }
2900 .execute()
2901 .await
2902 .unwrap_err();
2903 assert!(
2904 err.to_string().contains("not a removable worktree"),
2905 "{err}"
2906 );
2907 assert_eq!(server.await.unwrap().len(), 1);
2909 }
2910
2911 #[tokio::test]
2912 async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
2913 let err = CloseCommand {
2914 path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
2915 window_only: false,
2916 dry_run: false,
2917 yes: true,
2918 socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
2919 }
2920 .execute()
2921 .await
2922 .unwrap_err();
2923 assert!(
2924 err.to_string().contains("cannot resolve worktree path"),
2925 "{err}"
2926 );
2927 }
2928
2929 #[tokio::test]
2930 async fn show_closed_sets_and_reads() {
2931 let (_dir, sock, server) =
2933 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2934 ShowClosedCommand {
2935 value: Some(false),
2936 socket: Some(sock),
2937 }
2938 .execute()
2939 .await
2940 .unwrap();
2941 let reqs = server.await.unwrap();
2942 assert_eq!(reqs[0]["op"], "set-show-closed");
2943 assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
2944
2945 let (_dir, sock, server) = fake_daemon_seq(vec![
2947 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2948 ]);
2949 ShowClosedCommand {
2950 value: None,
2951 socket: Some(sock),
2952 }
2953 .execute()
2954 .await
2955 .unwrap();
2956 assert_eq!(server.await.unwrap()[0]["op"], "tree");
2958 }
2959
2960 #[tokio::test]
2961 async fn register_heartbeat_unregister_send_their_ops() {
2962 let (_dir, sock, server) =
2963 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2964 RegisterCommand {
2965 key: "w1".to_string(),
2966 folders: vec![PathBuf::from("/a")],
2967 repo_name: Some("r".to_string()),
2968 title: None,
2969 pid: Some(7),
2970 socket: Some(sock),
2971 }
2972 .execute()
2973 .await
2974 .unwrap();
2975 let reqs = server.await.unwrap();
2976 assert_eq!(reqs[0]["op"], "register");
2978 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2979 assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
2980 assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
2981 assert_eq!(reqs[0]["payload"]["pid"], json!(7));
2982
2983 let (_dir, sock, server) = fake_daemon_seq(vec![
2984 json!({ "ok": true, "payload": { "known": true, "close": true } }),
2985 ]);
2986 HeartbeatCommand {
2987 key: "w1".to_string(),
2988 socket: Some(sock),
2989 }
2990 .execute()
2991 .await
2992 .unwrap();
2993 let reqs = server.await.unwrap();
2994 assert_eq!(reqs[0]["op"], "heartbeat");
2995 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2996
2997 let (_dir, sock, server) =
2998 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
2999 UnregisterCommand {
3000 key: "w1".to_string(),
3001 socket: Some(sock),
3002 }
3003 .execute()
3004 .await
3005 .unwrap();
3006 let reqs = server.await.unwrap();
3007 assert_eq!(reqs[0]["op"], "unregister");
3008 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
3009 }
3010
3011 #[tokio::test]
3012 async fn tree_follow_renders_each_pushed_frame() {
3013 use crate::daemon::testutil::fake_daemon_stream;
3014
3015 let (_dir, sock, server) = fake_daemon_stream(vec![
3017 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3018 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
3019 ]);
3020 follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
3021 server.await.unwrap();
3022
3023 let (_dir, sock, server) = fake_daemon_stream(vec![
3026 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3027 ]);
3028 follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
3029 server.await.unwrap();
3030
3031 let (_dir, sock, server) = fake_daemon_stream(vec![
3034 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3035 ]);
3036 TreeCommand {
3037 socket: Some(sock),
3038 output: TableOrJson::Json,
3039 follow: true,
3040 }
3041 .execute()
3042 .await
3043 .unwrap();
3044 server.await.unwrap();
3045 }
3046
3047 #[tokio::test]
3048 async fn worktrees_command_routes_each_new_subcommand() {
3049 let target = tempfile::tempdir().unwrap();
3053 WorktreesCommand {
3055 command: WorktreesSubcommands::Close(CloseCommand {
3056 path: target.path().to_path_buf(),
3057 window_only: true,
3058 dry_run: true,
3059 yes: false,
3060 socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
3061 }),
3062 }
3063 .execute(None)
3064 .await
3065 .unwrap();
3066
3067 WorktreesCommand {
3070 command: WorktreesSubcommands::Rebase(RebaseCommand {
3071 paths: vec![target.path().to_path_buf()],
3072 dry_run: true,
3073 ..rebase_cmd()
3074 }),
3075 }
3076 .execute(None)
3077 .await
3078 .unwrap();
3079
3080 let (_d, sock, server) =
3082 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3083 WorktreesCommand {
3084 command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
3085 value: Some(true),
3086 socket: Some(sock),
3087 }),
3088 }
3089 .execute(None)
3090 .await
3091 .unwrap();
3092 server.await.unwrap();
3093
3094 let (_d, sock, server) = fake_daemon_seq(vec![json!({
3096 "ok": true,
3097 "payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
3098 })]);
3099 WorktreesCommand {
3100 command: WorktreesSubcommands::Reposition(RepositionCommand {
3101 paths: Vec::new(),
3102 reference: None,
3103 dry_run: false,
3104 undo: true,
3105 output: TableOrJson::Table,
3106 socket: Some(sock),
3107 }),
3108 }
3109 .execute(None)
3110 .await
3111 .unwrap();
3112 server.await.unwrap();
3113
3114 let (_d, sock, server) = fake_daemon_seq(vec![
3117 json!({ "ok": true, "payload": { "windows": [] } }),
3118 json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
3119 ]);
3120 WorktreesCommand {
3121 command: WorktreesSubcommands::Reload(ReloadCommand {
3122 paths: Vec::new(),
3123 output: TableOrJson::Table,
3124 socket: Some(sock),
3125 }),
3126 }
3127 .execute(None)
3128 .await
3129 .unwrap();
3130 server.await.unwrap();
3131
3132 let (_d, sock, server) =
3134 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3135 WorktreesCommand {
3136 command: WorktreesSubcommands::Register(RegisterCommand {
3137 key: "w1".to_string(),
3138 folders: vec![],
3139 repo_name: None,
3140 title: None,
3141 pid: None,
3142 socket: Some(sock),
3143 }),
3144 }
3145 .execute(None)
3146 .await
3147 .unwrap();
3148 server.await.unwrap();
3149
3150 let (_d, sock, server) =
3152 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
3153 WorktreesCommand {
3154 command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
3155 key: "w1".to_string(),
3156 socket: Some(sock),
3157 }),
3158 }
3159 .execute(None)
3160 .await
3161 .unwrap();
3162 server.await.unwrap();
3163
3164 let (_d, sock, server) =
3166 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
3167 WorktreesCommand {
3168 command: WorktreesSubcommands::Unregister(UnregisterCommand {
3169 key: "w1".to_string(),
3170 socket: Some(sock),
3171 }),
3172 }
3173 .execute(None)
3174 .await
3175 .unwrap();
3176 server.await.unwrap();
3177 }
3178
3179 #[tokio::test]
3180 async fn close_aborts_when_confirmation_is_declined() {
3181 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
3185 "ok": true,
3186 "payload": { "removable": true, "is_main": false, "open": false,
3187 "window_folder_count": 0, "risks": [], "info": [] }
3188 })]);
3189 let target = tempfile::tempdir().unwrap();
3190 CloseCommand {
3191 path: target.path().to_path_buf(),
3192 window_only: false,
3193 dry_run: false,
3194 yes: false,
3195 socket: Some(sock),
3196 }
3197 .execute_with(|_has_risks| async { false })
3198 .await
3199 .unwrap();
3200 assert_eq!(server.await.unwrap().len(), 1);
3201 }
3202
3203 #[tokio::test]
3204 async fn close_deletes_when_confirmation_is_accepted() {
3205 let (_dir, sock, server) = fake_daemon_seq(vec![
3208 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
3209 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
3210 json!({ "ok": true, "payload": { "removed": true } }),
3211 ]);
3212 let target = tempfile::tempdir().unwrap();
3213 CloseCommand {
3214 path: target.path().to_path_buf(),
3215 window_only: false,
3216 dry_run: false,
3217 yes: false,
3218 socket: Some(sock),
3219 }
3220 .execute_with(|_has_risks| async { true })
3221 .await
3222 .unwrap();
3223 let reqs = server.await.unwrap();
3224 assert_eq!(reqs.len(), 2);
3225 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
3226 }
3227
3228 #[tokio::test]
3229 async fn confirm_removal_with_decides_from_the_answer() {
3230 assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
3233 assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
3234 assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
3235 assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
3236 assert!(!confirm_removal_with(false, async { None }).await);
3237 }
3238
3239 fn rebase_cmd() -> RebaseCommand {
3243 RebaseCommand {
3244 paths: Vec::new(),
3245 all: false,
3246 onto: None,
3247 autostash: false,
3248 dry_run: false,
3249 keep_conflicts: false,
3250 yes: false,
3251 output: TableOrJson::Table,
3252 }
3253 }
3254
3255 #[test]
3256 fn rebase_parses_paths_and_flags() {
3257 let cmd = RebaseCommand::try_parse_from([
3258 "rebase",
3259 "/wt/a",
3260 "/wt/b",
3261 "--onto",
3262 "origin/release",
3263 "--autostash",
3264 "--dry-run",
3265 "--keep-conflicts",
3266 "-y",
3267 "-o",
3268 "json",
3269 ])
3270 .unwrap();
3271 assert_eq!(
3272 cmd.paths,
3273 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3274 );
3275 assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
3276 assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
3277 assert!(matches!(cmd.output, TableOrJson::Json));
3278 }
3279
3280 #[test]
3281 fn rebase_defaults_are_conservative() {
3282 let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
3283 assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
3284 assert!(!cmd.keep_conflicts);
3287 assert_eq!(cmd.onto, None);
3288 assert!(matches!(cmd.output, TableOrJson::Table));
3289 }
3290
3291 #[test]
3292 fn rebase_requires_a_target() {
3293 let err = rebase_cmd().selection(None).unwrap_err().to_string();
3296 assert!(err.contains("--all"), "expected a usage hint, got: {err}");
3297 }
3298
3299 #[test]
3300 fn rebase_rejects_paths_together_with_all() {
3301 let cmd = RebaseCommand {
3302 paths: vec![PathBuf::from("/wt/a")],
3303 all: true,
3304 ..rebase_cmd()
3305 };
3306 let err = cmd.selection(None).unwrap_err().to_string();
3307 assert!(err.contains("not both"), "got: {err}");
3308 }
3309
3310 #[test]
3311 fn rebase_selection_maps_paths_and_all() {
3312 let cmd = RebaseCommand {
3313 paths: vec![PathBuf::from("/wt/a")],
3314 ..rebase_cmd()
3315 };
3316 assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
3317 let all = RebaseCommand {
3318 all: true,
3319 ..rebase_cmd()
3320 };
3321 assert!(matches!(
3322 all.selection(None).unwrap(),
3323 Selection::All { .. }
3324 ));
3325 }
3326
3327 #[test]
3328 fn rebase_prompt_agrees_in_number() {
3329 assert!(rebase_prompt(1).contains("1 worktree ("));
3330 assert!(rebase_prompt(3).contains("3 worktrees ("));
3331 assert!(rebase_prompt(2).contains("rewrites branch history"));
3333 }
3334
3335 #[tokio::test]
3336 async fn confirm_rebase_with_decides_from_the_answer() {
3337 assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
3338 assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
3339 assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
3340 assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
3341 assert!(!confirm_rebase_with(1, async { None }).await);
3342 }
3343
3344 #[test]
3345 fn fetch_line_reports_each_repos_single_fetch() {
3346 let ok = FetchOutcome {
3347 repo_root: PathBuf::from("/repo"),
3348 onto: "origin/main".to_string(),
3349 fetched: true,
3350 ok: true,
3351 detail: None,
3352 };
3353 assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
3354
3355 let failed = FetchOutcome {
3356 detail: Some("host unreachable".to_string()),
3357 ok: false,
3358 ..ok.clone()
3359 };
3360 assert!(fetch_line(&failed).contains("FAILED"));
3361
3362 let local = FetchOutcome {
3363 fetched: false,
3364 onto: "develop".to_string(),
3365 ..ok
3366 };
3367 assert!(fetch_line(&local).contains("nothing fetched"));
3368 }
3369
3370 #[test]
3371 fn outcome_rows_render_each_status() {
3372 let row = |result| {
3373 outcome_row(&WorktreeOutcome {
3374 path: PathBuf::from("/wt"),
3375 branch: Some("feature".to_string()),
3376 onto: "origin/main".to_string(),
3377 result,
3378 })
3379 };
3380 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
3381 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
3382 assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
3383 assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
3384 assert!(row(RebaseResult::Skipped {
3385 reason: SkipReason::Dirty
3386 })
3387 .contains("--autostash"));
3388 assert!(row(RebaseResult::Conflict {
3389 detail: "CONFLICT (content)".to_string(),
3390 left_in_place: false,
3391 })
3392 .contains("conflict"));
3393 let kept = row(RebaseResult::Conflict {
3396 detail: "CONFLICT (content)".to_string(),
3397 left_in_place: true,
3398 });
3399 assert!(kept.contains("conflict"), "{kept}");
3400 assert!(kept.contains("git rebase --continue"), "{kept}");
3401 assert!(row(RebaseResult::FetchFailed {
3402 detail: "host unreachable".to_string()
3403 })
3404 .contains("fetch-failed"));
3405 assert!(row(RebaseResult::Skipped {
3407 reason: SkipReason::DetachedHead
3408 })
3409 .contains("detached HEAD"));
3410 assert!(row(RebaseResult::Skipped {
3411 reason: SkipReason::OperationInProgress
3412 })
3413 .contains("in progress"));
3414 assert!(row(RebaseResult::Skipped {
3415 reason: SkipReason::NotAWorktree
3416 })
3417 .contains("not a git worktree"));
3418 assert!(row(RebaseResult::Skipped {
3419 reason: SkipReason::NoOntoRef
3420 })
3421 .contains("resolve the target ref"));
3422 }
3423
3424 #[test]
3425 fn print_emits_both_json_and_table_without_error() {
3426 let fetches = vec![FetchOutcome {
3427 repo_root: PathBuf::from("/r"),
3428 onto: "origin/main".to_string(),
3429 fetched: true,
3430 ok: true,
3431 detail: None,
3432 }];
3433 let outcomes = vec![WorktreeOutcome {
3434 path: PathBuf::from("/wt"),
3435 branch: Some("feature".to_string()),
3436 onto: "origin/main".to_string(),
3437 result: RebaseResult::UpToDate,
3438 }];
3439 let json_cmd = RebaseCommand {
3441 dry_run: true,
3442 output: TableOrJson::Json,
3443 ..rebase_cmd()
3444 };
3445 json_cmd.print(true, &fetches, &outcomes).unwrap();
3446 rebase_cmd().print(false, &fetches, &outcomes).unwrap();
3447 }
3448
3449 #[test]
3450 fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
3451 assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
3452 let long = "x".repeat(200);
3453 let out = brief(&long);
3454 assert_eq!(out.chars().count(), 100);
3455 assert!(out.ends_with("..."));
3456 assert_eq!(brief("a\u{7}b"), "ab");
3458 }
3459
3460 #[test]
3461 fn empty_report_renders_placeholders() {
3462 assert_eq!(render_fetches(&[]), "No repository selected.");
3463 assert_eq!(render_outcomes(&[]), "No worktrees selected.");
3464 }
3465
3466 #[allow(clippy::await_holding_lock)]
3472 #[tokio::test]
3473 async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
3474 let _guard = crate::git::worktree_batch::test_serial_lock();
3479 let Some(scenario) = BehindScenario::build() else {
3480 return; };
3482 let before = scenario.worktree_head();
3483 RebaseCommand {
3484 paths: vec![scenario.worktree.clone()],
3485 ..rebase_cmd()
3486 }
3487 .execute_with(None, |pending| async move {
3488 assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
3489 false
3490 })
3491 .await
3492 .unwrap();
3493 assert_eq!(
3494 scenario.worktree_head(),
3495 before,
3496 "declining the confirm must not rebase"
3497 );
3498 }
3499
3500 #[allow(clippy::await_holding_lock)]
3503 #[tokio::test]
3504 async fn rebase_confirmed_rebases_the_behind_worktree() {
3505 let _guard = crate::git::worktree_batch::test_serial_lock();
3508 let Some(scenario) = BehindScenario::build() else {
3509 return; };
3511 let before = scenario.worktree_head();
3512 RebaseCommand {
3513 paths: vec![scenario.worktree.clone()],
3514 ..rebase_cmd()
3515 }
3516 .execute_with(None, |pending| async move {
3517 assert_eq!(pending, 1);
3518 true
3519 })
3520 .await
3521 .unwrap();
3522 assert_ne!(
3523 scenario.worktree_head(),
3524 before,
3525 "confirming the prompt must rebase the worktree"
3526 );
3527 }
3528
3529 struct BehindScenario {
3533 _root: tempfile::TempDir,
3534 worktree: PathBuf,
3535 }
3536
3537 impl BehindScenario {
3538 fn build() -> Option<Self> {
3539 use git2::Repository;
3540 let root = tempfile::tempdir().ok()?;
3541 let origin = root.path().join("origin.git");
3542 let local = root.path().join("local");
3543 let worktree = root.path().join("feature");
3544 std::fs::create_dir_all(&origin).ok()?;
3545 std::fs::create_dir_all(&local).ok()?;
3546 run(&origin, &["init", "--bare", "-b", "main"])?;
3547 run(&local, &["init", "-b", "main"])?;
3548 Self::identity(&local)?;
3549 std::fs::write(local.join("f.txt"), "one\n").ok()?;
3550 run(&local, &["add", "f.txt"])?;
3551 run(&local, &["commit", "-m", "one"])?;
3552 run(&local, &["remote", "add", "origin", origin.to_str()?])?;
3553 run(&local, &["push", "-u", "origin", "main"])?;
3554 run(
3555 &local,
3556 &[
3557 "worktree",
3558 "add",
3559 "-b",
3560 "feature",
3561 worktree.to_str()?,
3562 "main",
3563 ],
3564 )?;
3565 let repo = Repository::open_bare(&origin).ok()?;
3568 let parent = repo
3569 .find_commit(repo.refname_to_id("refs/heads/main").ok()?)
3570 .ok()?;
3571 let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
3572 let blob = repo.blob(b"two\n").ok()?;
3573 builder.insert("f.txt", blob, 0o100_644).ok()?;
3574 let tree = repo.find_tree(builder.write().ok()?).ok()?;
3575 let sig = git2::Signature::now("Other", "other@example.com").ok()?;
3576 repo.commit(
3577 Some("refs/heads/main"),
3578 &sig,
3579 &sig,
3580 "two",
3581 &tree,
3582 &[&parent],
3583 )
3584 .ok()?;
3585 Some(Self {
3586 _root: root,
3587 worktree,
3588 })
3589 }
3590
3591 fn identity(dir: &Path) -> Option<()> {
3594 run(dir, &["config", "user.name", "Test"])?;
3595 run(dir, &["config", "user.email", "test@example.com"])?;
3596 run(dir, &["config", "commit.gpgsign", "false"])
3597 }
3598
3599 fn worktree_head(&self) -> String {
3600 let out = std::process::Command::new("git")
3601 .current_dir(&self.worktree)
3602 .args(["rev-parse", "HEAD"])
3603 .output();
3604 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3605 .unwrap_or_default()
3606 }
3607 }
3608
3609 fn run(dir: &Path, args: &[&str]) -> Option<()> {
3611 let output = std::process::Command::new("git")
3612 .current_dir(dir)
3613 .args(args)
3614 .output()
3615 .ok()?;
3616 output.status.success().then_some(())
3617 }
3618
3619 #[test]
3622 fn merge_queue_parses_paths_and_flags() {
3623 assert!(matches!(
3625 parse(&["merge-queue", "/a"]),
3626 WorktreesSubcommands::MergeQueue(_)
3627 ));
3628 let cmd =
3630 MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
3631 assert_eq!(cmd.paths.len(), 2);
3632 assert!(cmd.check);
3633 assert!(!cmd.yes);
3634 assert!(cmd.socket.is_none());
3635 let cmd = MergeQueueCommand::try_parse_from([
3637 "merge-queue",
3638 "/a",
3639 "-y",
3640 "--socket",
3641 "/tmp/d.sock",
3642 ])
3643 .unwrap();
3644 assert!(cmd.yes);
3645 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
3646 assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
3648 }
3649
3650 #[test]
3651 fn render_eligibility_report_lists_eligible_and_skipped() {
3652 let report = json!({
3653 "eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
3654 "skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
3655 });
3656 let out = render_eligibility_report(&report);
3657 assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
3658 assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
3659 assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
3660 }
3661
3662 #[test]
3663 fn render_enqueue_result_marks_already_queued_and_failures() {
3664 let result = json!({
3665 "queued": [
3666 { "number": 10, "path": "/a" },
3667 { "number": 11, "path": "/b", "already_queued": true },
3668 ],
3669 "failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
3670 "skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
3671 });
3672 let out = render_enqueue_result(&result);
3673 assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
3674 assert!(out.contains("queued: PR #10"), "{out}");
3675 assert!(out.contains("PR #11 (already queued)"), "{out}");
3676 assert!(
3677 out.contains("failed: PR #12 — merge queue not enabled"),
3678 "{out}"
3679 );
3680 }
3681
3682 #[test]
3683 fn render_eligibility_report_strips_control_bytes() {
3684 let report = json!({
3687 "eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
3688 "skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
3689 });
3690 let out = render_eligibility_report(&report);
3691 assert!(
3692 !out.contains(|c: char| c.is_control() && c != '\n'),
3693 "{out:?}"
3694 );
3695 }
3696
3697 #[tokio::test]
3698 async fn confirm_enqueue_with_decides_from_the_answer() {
3699 assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
3700 assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
3701 assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
3702 assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
3703 assert!(!confirm_enqueue_with(3, async { None }).await);
3704 }
3705
3706 #[tokio::test]
3707 async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
3708 let cmd = MergeQueueCommand {
3711 paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
3712 check: true,
3713 yes: false,
3714 socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
3715 };
3716 let err = cmd.execute().await.unwrap_err();
3717 assert!(
3718 err.to_string().contains("cannot resolve worktree path"),
3719 "{err}"
3720 );
3721 }
3722
3723 #[tokio::test]
3724 async fn merge_queue_check_prints_the_report_and_never_confirms() {
3725 let target = tempfile::tempdir().unwrap();
3726 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3727 "ok": true,
3728 "payload": {
3729 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3730 "skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
3731 }
3732 })]);
3733 let cmd = MergeQueueCommand {
3734 paths: vec![target.path().to_path_buf()],
3735 check: true,
3736 yes: false,
3737 socket: Some(sock),
3738 };
3739 cmd.execute_with(|_| async { panic!("must not confirm on --check") })
3741 .await
3742 .unwrap();
3743 server.await.unwrap();
3744 }
3745
3746 #[tokio::test]
3747 async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
3748 let target = tempfile::tempdir().unwrap();
3749 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3750 "ok": true,
3751 "payload": {
3752 "eligible": [],
3753 "skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
3754 }
3755 })]);
3756 let cmd = MergeQueueCommand {
3757 paths: vec![target.path().to_path_buf()],
3758 check: false,
3759 yes: false,
3760 socket: Some(sock),
3761 };
3762 cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
3764 .await
3765 .unwrap();
3766 server.await.unwrap();
3767 }
3768
3769 #[tokio::test]
3770 async fn merge_queue_aborts_when_confirmation_is_declined() {
3771 let target = tempfile::tempdir().unwrap();
3772 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3773 "ok": true,
3774 "payload": {
3775 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3776 "skipped": [],
3777 }
3778 })]);
3779 let cmd = MergeQueueCommand {
3780 paths: vec![target.path().to_path_buf()],
3781 check: false,
3782 yes: false,
3783 socket: Some(sock),
3784 };
3785 cmd.execute_with(|count| async move {
3787 assert_eq!(count, 1);
3788 false
3789 })
3790 .await
3791 .unwrap();
3792 server.await.unwrap();
3793 }
3794
3795 #[tokio::test]
3796 async fn merge_queue_enqueues_after_confirmation() {
3797 let target = tempfile::tempdir().unwrap();
3798 let (_dir, sock, server) = fake_daemon_replies(vec![
3799 json!({
3800 "ok": true,
3801 "payload": {
3802 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3803 "skipped": [],
3804 }
3805 }),
3806 json!({
3807 "ok": true,
3808 "payload": {
3809 "queued": [{ "path": "/a", "number": 9 }],
3810 "skipped": [],
3811 "failed": [],
3812 }
3813 }),
3814 ]);
3815 let cmd = MergeQueueCommand {
3816 paths: vec![target.path().to_path_buf()],
3817 check: false,
3818 yes: false,
3819 socket: Some(sock),
3820 };
3821 cmd.execute_with(|_| async { true }).await.unwrap();
3823 server.await.unwrap();
3824 }
3825
3826 #[tokio::test]
3827 async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
3828 let target = tempfile::tempdir().unwrap();
3832 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3833 "ok": true,
3834 "payload": { "eligible": [], "skipped": [] }
3835 })]);
3836 let cmd = WorktreesCommand {
3837 command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
3838 paths: vec![target.path().to_path_buf()],
3839 check: true,
3840 yes: false,
3841 socket: Some(sock),
3842 }),
3843 };
3844 cmd.execute(None).await.unwrap();
3845 server.await.unwrap();
3846 }
3847
3848 #[test]
3851 fn reposition_parses_flags_and_enforces_the_undo_split() {
3852 let WorktreesSubcommands::Reposition(cmd) = parse(&[
3853 "reposition",
3854 "--reference",
3855 "/wt/ref",
3856 "/wt/a",
3857 "/wt/b",
3858 "--dry-run",
3859 "-o",
3860 "json",
3861 ]) else {
3862 panic!("expected the Reposition variant");
3863 };
3864 assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
3865 assert_eq!(
3866 cmd.paths,
3867 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3868 );
3869 assert!(cmd.dry_run);
3870 assert!(!cmd.undo);
3871 assert_eq!(cmd.output, TableOrJson::Json);
3872
3873 let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
3875 panic!("expected the Reposition variant");
3876 };
3877 assert!(undo.undo);
3878 assert!(undo.reference.is_none());
3879 }
3880
3881 #[test]
3882 fn reposition_rejects_a_missing_reference_and_undo_combinations() {
3883 assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
3886 assert!(RepositionCommand::try_parse_from([
3889 "reposition",
3890 "--undo",
3891 "--reference",
3892 "/wt/ref",
3893 ])
3894 .is_err());
3895 assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
3896 }
3897
3898 #[test]
3899 fn window_key_for_matches_a_canonicalized_folder() {
3900 let dir = tempfile::tempdir_in("/tmp").unwrap();
3901 let wt = dir.path().join("tree");
3902 std::fs::create_dir(&wt).unwrap();
3903 let canonical = std::fs::canonicalize(&wt).unwrap();
3904 let windows = json!({
3905 "windows": [
3906 { "key": "other", "folders": ["/definitely/not/here"] },
3907 { "key": "wanted", "folders": [canonical.to_string_lossy()] },
3908 ]
3909 });
3910 assert_eq!(
3911 window_key_for(&windows, &wt, "repositioned").unwrap(),
3912 "wanted"
3913 );
3914 }
3915
3916 #[test]
3917 fn window_key_for_errors_when_no_window_has_it_open() {
3918 let dir = tempfile::tempdir_in("/tmp").unwrap();
3922 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
3923 .expect_err("an unopened worktree must not resolve");
3924 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3925 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
3928 .expect_err("an unopened worktree must not resolve");
3929 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3930 let missing = dir.path().join("gone");
3932 let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
3933 .expect_err("a nonexistent path must not resolve");
3934 assert!(err.to_string().contains("cannot resolve"), "{err:#}");
3935 }
3936
3937 #[test]
3938 fn reload_command_requires_at_least_one_path() {
3939 assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
3942 let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
3943 assert_eq!(cmd.paths.len(), 2);
3944 assert!(matches!(cmd.output, TableOrJson::Table));
3945 assert!(cmd.socket.is_none());
3946 }
3947
3948 #[test]
3949 fn render_reload_reports_what_was_signalled_not_reloaded() {
3950 let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
3953 assert_eq!(out, "Signalled 2 of 2 windows to reload.");
3954 assert!(!out.contains("Reloaded"), "{out}");
3955 let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
3957 assert_eq!(one, "Signalled 1 of 1 window to reload.");
3958 }
3959
3960 #[test]
3961 fn render_reload_names_windows_that_had_already_closed() {
3962 let out = render_reload(&json!({
3965 "requested": 3,
3966 "signalled": 1,
3967 "unknown": ["w2", "w3"],
3968 }));
3969 assert!(
3970 out.starts_with("Signalled 1 of 3 windows to reload."),
3971 "{out}"
3972 );
3973 assert!(out.contains("No longer open"), "{out}");
3974 assert!(out.contains("w2, w3"), "{out}");
3975 }
3976
3977 #[test]
3978 fn render_reload_tolerates_a_reply_missing_every_field() {
3979 assert_eq!(
3982 render_reload(&json!({})),
3983 "Signalled 0 of 0 windows to reload."
3984 );
3985 }
3986
3987 #[test]
3988 fn render_reposition_explains_a_missing_permission() {
3989 let out = render_reposition(&json!({ "trusted": false, "results": [] }));
3990 assert!(out.contains("Accessibility permission"), "{out}");
3991 assert!(out.contains("daemon restart"), "{out}");
3992 }
3993
3994 #[test]
3995 fn render_reposition_reports_a_blocked_batch() {
3996 let out = render_reposition(&json!({
3997 "trusted": true,
3998 "blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
3999 "results": [],
4000 }));
4001 assert!(out.contains("Nothing was moved"), "{out}");
4002 assert!(out.contains("reference-ambiguous"), "{out}");
4003 assert!(out.contains("2 windows match"), "{out}");
4004 }
4005
4006 #[test]
4007 fn render_reposition_renders_the_reference_and_per_target_outcomes() {
4008 let out = render_reposition(&json!({
4009 "trusted": true,
4010 "reference": {
4011 "key": "r",
4012 "title": "ref-tree",
4013 "frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
4014 },
4015 "moved": 1,
4016 "skipped": 1,
4017 "results": [
4018 { "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
4019 { "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
4020 ],
4021 }));
4022 assert!(
4023 out.contains("Reference: ref-tree 800×600 at (10, 21)"),
4024 "{out}"
4025 );
4026 assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
4027 assert!(out.contains("moved: a-tree"), "{out}");
4028 assert!(out.contains("ambiguous: b-tree"), "{out}");
4029 }
4030
4031 #[test]
4032 fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
4033 let out = render_reposition(&json!({
4036 "trusted": true,
4037 "results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
4038 }));
4039 assert!(out.contains("no-window: keyless"), "{out}");
4040 let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
4042 assert!(empty.contains("(nothing to report)"), "{empty}");
4043 }
4044
4045 #[test]
4046 fn render_reposition_strips_control_bytes_from_daemon_strings() {
4047 let out = render_reposition(&json!({
4050 "trusted": true,
4051 "results": [{
4052 "key": "k",
4053 "title": "evil\u{1b}[31mred",
4054 "outcome": "moved",
4055 "detail": "ok\u{7}",
4056 }],
4057 }));
4058 assert!(!out.contains('\u{1b}'), "{out:?}");
4059 assert!(!out.contains('\u{7}'), "{out:?}");
4060 }
4061
4062 #[test]
4063 fn render_frame_formats_or_dashes() {
4064 assert_eq!(render_frame(None), "-");
4065 assert_eq!(
4066 render_frame(Some(
4067 &json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
4068 )),
4069 "100×50 at (2, -2)"
4070 );
4071 assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
4073 }
4074
4075 #[test]
4076 fn print_reposition_emits_both_formats() {
4077 let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
4078 print_reposition(TableOrJson::Table, &reply).unwrap();
4079 print_reposition(TableOrJson::Json, &reply).unwrap();
4080 }
4081
4082 #[tokio::test]
4083 async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
4084 let dir = tempfile::tempdir_in("/tmp").unwrap();
4085 let reference = dir.path().join("ref");
4086 let target = dir.path().join("tgt");
4087 std::fs::create_dir(&reference).unwrap();
4088 std::fs::create_dir(&target).unwrap();
4089 let (canon_ref, canon_tgt) = (
4090 std::fs::canonicalize(&reference).unwrap(),
4091 std::fs::canonicalize(&target).unwrap(),
4092 );
4093
4094 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4097 json!({ "ok": true, "payload": { "windows": [
4098 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4099 { "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
4100 ] } }),
4101 json!({ "ok": true, "payload": {
4102 "trusted": true,
4103 "moved": 1,
4104 "skipped": 0,
4105 "results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
4106 } }),
4107 ]);
4108
4109 RepositionCommand {
4110 paths: vec![target],
4111 reference: Some(reference),
4112 dry_run: false,
4113 undo: false,
4114 output: TableOrJson::Json,
4115 socket: Some(sock),
4116 }
4117 .execute()
4118 .await
4119 .unwrap();
4120 server.await.unwrap();
4121 }
4122
4123 #[tokio::test]
4124 async fn reposition_undo_skips_the_list_lookup_entirely() {
4125 let (_dir, sock, server) = fake_daemon_reply(json!({
4128 "ok": true,
4129 "payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
4130 }));
4131 RepositionCommand {
4132 paths: Vec::new(),
4133 reference: None,
4134 dry_run: false,
4135 undo: true,
4136 output: TableOrJson::Table,
4137 socket: Some(sock),
4138 }
4139 .execute()
4140 .await
4141 .unwrap();
4142 server.await.unwrap();
4143 }
4144
4145 #[tokio::test]
4146 async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
4147 let dir = tempfile::tempdir_in("/tmp").unwrap();
4148 let reference = dir.path().join("ref");
4149 let target = dir.path().join("tgt");
4150 std::fs::create_dir(&reference).unwrap();
4151 std::fs::create_dir(&target).unwrap();
4152 let canon_ref = std::fs::canonicalize(&reference).unwrap();
4153
4154 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4157 "ok": true,
4158 "payload": { "windows": [
4159 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4160 ] },
4161 }));
4162 let err = RepositionCommand {
4163 paths: vec![target],
4164 reference: Some(reference),
4165 dry_run: true,
4166 undo: false,
4167 output: TableOrJson::Table,
4168 socket: Some(sock),
4169 }
4170 .execute()
4171 .await
4172 .expect_err("an unopened target must abort the command");
4173 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
4174 server.await.unwrap();
4175 }
4176
4177 #[tokio::test]
4178 async fn reposition_surfaces_a_daemon_error() {
4179 let (_dir, sock, server) = fake_daemon_reply(json!({
4180 "ok": false,
4181 "error": "unknown worktrees op: reposition",
4182 }));
4183 let err = RepositionCommand {
4184 paths: Vec::new(),
4185 reference: None,
4186 dry_run: false,
4187 undo: true,
4188 output: TableOrJson::Table,
4189 socket: Some(sock),
4190 }
4191 .execute()
4192 .await
4193 .expect_err("an `ok:false` reply must not be reported as success");
4194 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4195 server.await.unwrap();
4196 }
4197
4198 fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
4201 let dir = tempfile::tempdir_in("/tmp").unwrap();
4202 let a = dir.path().join("a");
4203 let b = dir.path().join("b");
4204 std::fs::create_dir(&a).unwrap();
4205 std::fs::create_dir(&b).unwrap();
4206 let list = json!({ "ok": true, "payload": { "windows": [
4207 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4208 { "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
4209 ] } });
4210 (dir, a, b, list)
4211 }
4212
4213 #[tokio::test]
4214 async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
4215 let (_dir, a, b, list) = reload_fixture();
4216 let (_sock_dir, sock, server) = fake_daemon_seq(vec![
4219 list,
4220 json!({ "ok": true, "payload": {
4221 "requested": 2, "signalled": 2, "unknown": [],
4222 } }),
4223 ]);
4224
4225 ReloadCommand {
4226 paths: vec![a, b],
4227 output: TableOrJson::Table,
4228 socket: Some(sock),
4229 }
4230 .execute()
4231 .await
4232 .unwrap();
4233
4234 let requests = server.await.unwrap();
4237 assert_eq!(requests[1]["op"], "reload");
4238 assert_eq!(
4239 requests[1]["payload"]["target_keys"],
4240 json!(["key-a", "key-b"])
4241 );
4242 assert!(
4243 requests[1]["payload"].get("requester_key").is_none(),
4244 "a CLI process is not a window, so it must not claim to be one"
4245 );
4246 }
4247
4248 #[tokio::test]
4249 async fn reload_json_output_passes_the_reply_through_verbatim() {
4250 let (_dir, a, _b, list) = reload_fixture();
4251 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4252 list,
4253 json!({ "ok": true, "payload": {
4254 "requested": 1, "signalled": 0, "unknown": ["key-a"],
4255 } }),
4256 ]);
4257 ReloadCommand {
4260 paths: vec![a],
4261 output: TableOrJson::Json,
4262 socket: Some(sock),
4263 }
4264 .execute()
4265 .await
4266 .unwrap();
4267 server.await.unwrap();
4268 }
4269
4270 #[tokio::test]
4271 async fn reload_fails_before_the_op_when_a_target_has_no_window() {
4272 let (_dir, a, b, _list) = reload_fixture();
4273 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4277 "ok": true,
4278 "payload": { "windows": [
4279 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4280 ] },
4281 }));
4282 let err = ReloadCommand {
4283 paths: vec![a, b],
4284 output: TableOrJson::Table,
4285 socket: Some(sock),
4286 }
4287 .execute()
4288 .await
4289 .expect_err("an unopened target must abort the command");
4290 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
4291 server.await.unwrap();
4292 }
4293
4294 #[tokio::test]
4295 async fn reload_surfaces_a_daemon_error() {
4296 let (_dir, a, _b, list) = reload_fixture();
4297 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4298 list,
4299 json!({ "ok": false, "error": "unknown worktrees op: reload" }),
4300 ]);
4301 let err = ReloadCommand {
4304 paths: vec![a],
4305 output: TableOrJson::Table,
4306 socket: Some(sock),
4307 }
4308 .execute()
4309 .await
4310 .expect_err("an `ok:false` reply must not be reported as success");
4311 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4312 server.await.unwrap();
4313 }
4314
4315 fn push_cmd() -> PushCommand {
4319 PushCommand {
4320 paths: Vec::new(),
4321 all: false,
4322 dry_run: false,
4323 yes: false,
4324 output: TableOrJson::Table,
4325 }
4326 }
4327
4328 #[test]
4329 fn push_parses_paths_and_flags() {
4330 let cmd = PushCommand::try_parse_from(["push", "/a", "/b", "--dry-run", "-y"]).unwrap();
4331 assert_eq!(cmd.paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
4332 assert!(cmd.dry_run && cmd.yes);
4333 assert!(!cmd.all);
4334 }
4335
4336 #[test]
4337 fn push_exposes_no_force_escape_hatch() {
4338 for flag in ["--force", "-f", "--no-force-if-includes"] {
4341 assert!(
4342 PushCommand::try_parse_from(["push", "/a", flag]).is_err(),
4343 "{flag} must not be accepted"
4344 );
4345 }
4346 }
4347
4348 #[test]
4349 fn push_selection_requires_paths_or_all() {
4350 let err = push_cmd().selection(None).unwrap_err().to_string();
4351 assert!(err.contains("--all"), "{err}");
4352
4353 let both = PushCommand {
4354 paths: vec![PathBuf::from("/a")],
4355 all: true,
4356 ..push_cmd()
4357 };
4358 let err = both.selection(None).unwrap_err().to_string();
4359 assert!(err.contains("not both"), "{err}");
4360 }
4361
4362 #[test]
4363 fn push_selection_resolves_relative_paths_against_the_repo_flag() {
4364 let cmd = PushCommand {
4365 paths: vec![PathBuf::from("wt-a"), PathBuf::from("/abs/wt-b")],
4366 ..push_cmd()
4367 };
4368 let Selection::Paths(paths) = cmd.selection(Some(Path::new("/base"))).unwrap() else {
4369 panic!("expected an explicit path selection");
4370 };
4371 assert_eq!(
4372 paths,
4373 vec![PathBuf::from("/base/wt-a"), PathBuf::from("/abs/wt-b")],
4374 "a relative path resolves against -C, an absolute one is left alone"
4375 );
4376 }
4377
4378 #[tokio::test]
4379 async fn push_dry_run_reaches_no_remote_and_pushes_nothing() {
4380 let dir = tempfile::tempdir().unwrap();
4383 PushCommand {
4384 paths: vec![dir.path().to_path_buf()],
4385 dry_run: true,
4386 ..push_cmd()
4387 }
4388 .execute_with(None, |_, _| async { panic!("a dry run must not confirm") })
4389 .await
4390 .unwrap();
4391 }
4392
4393 #[tokio::test]
4394 async fn push_declining_the_confirmation_publishes_nothing() {
4395 let (_root, origin, wt) = push_scenario();
4396 let before = origin_tip(&origin, "refs/heads/feature");
4397
4398 PushCommand {
4399 paths: vec![wt],
4400 ..push_cmd()
4401 }
4402 .execute_with(None, |pending, forced| async move {
4403 assert_eq!((pending, forced), (1, 1));
4404 false
4405 })
4406 .await
4407 .unwrap();
4408
4409 assert_eq!(
4410 origin_tip(&origin, "refs/heads/feature"),
4411 before,
4412 "declining must leave the remote exactly as it was"
4413 );
4414 }
4415
4416 #[tokio::test]
4417 async fn push_confirming_force_pushes_with_the_lease() {
4418 let (_root, origin, wt) = push_scenario();
4419 let rewritten = git2::Repository::open(&wt)
4420 .unwrap()
4421 .head()
4422 .unwrap()
4423 .target();
4424
4425 PushCommand {
4426 paths: vec![wt],
4427 ..push_cmd()
4428 }
4429 .execute_with(None, |_, _| async { true })
4430 .await
4431 .unwrap();
4432
4433 assert_eq!(
4434 origin_tip(&origin, "refs/heads/feature"),
4435 rewritten,
4436 "confirming publishes the rewritten tip"
4437 );
4438 }
4439
4440 #[test]
4441 fn push_prompt_calls_out_the_force_count_separately() {
4442 assert_eq!(push_prompt(1, 0), "Push 1 branch? [y/N] ");
4443 assert_eq!(push_prompt(3, 0), "Push 3 branches? [y/N] ");
4444
4445 let forced = push_prompt(3, 2);
4446 assert!(forced.contains("force-pushing 2 with a lease"), "{forced}");
4447 assert!(
4448 forced.contains("rewritten history"),
4449 "the prompt must say what is actually being published: {forced}"
4450 );
4451 }
4452
4453 #[tokio::test]
4454 async fn push_confirmation_treats_anything_but_yes_as_no() {
4455 assert!(confirm_push_with(1, 1, async { Some("y\n".into()) }).await);
4456 assert!(confirm_push_with(1, 1, async { Some("YES".into()) }).await);
4457 assert!(!confirm_push_with(1, 1, async { Some("n".into()) }).await);
4458 assert!(
4459 !confirm_push_with(1, 1, async { None }).await,
4460 "EOF must never be read as consent"
4461 );
4462 }
4463
4464 #[test]
4465 fn push_rows_render_each_status_with_its_own_instruction() {
4466 let outcome = |result| worktree_push::WorktreeOutcome {
4467 path: PathBuf::from("/wt"),
4468 branch: Some("feature".into()),
4469 remote: "origin".into(),
4470 remote_branch: "feature".into(),
4471 result,
4472 };
4473 let rendered = render_push_outcomes(&[
4474 outcome(worktree_push::PushResult::WouldForce {
4475 ahead: 2,
4476 behind: 1,
4477 }),
4478 outcome(worktree_push::PushResult::Rejected {
4479 detail: "stale info".into(),
4480 stale: true,
4481 }),
4482 outcome(worktree_push::PushResult::Skipped {
4483 reason: worktree_push::SkipReason::DefaultBranchForcePush,
4484 }),
4485 ]);
4486 assert!(rendered.contains("would-force"), "{rendered}");
4487 assert!(rendered.contains("origin/feature"), "{rendered}");
4488 assert!(
4489 rendered.contains("`git fetch` and rebase"),
4490 "a lease refusal must name the fix, not just quote git: {rendered}"
4491 );
4492 assert!(
4493 rendered.contains("refusing to force-push the remote default branch"),
4494 "{rendered}"
4495 );
4496 }
4497
4498 #[test]
4499 fn push_renders_an_empty_selection_without_a_bare_header() {
4500 assert_eq!(render_push_outcomes(&[]), "No worktrees selected.");
4501 }
4502
4503 #[test]
4504 fn push_rows_render_every_remaining_status_and_skip_reason() {
4505 use worktree_push::{PushResult, SkipReason};
4509 let outcome = |result| worktree_push::WorktreeOutcome {
4510 path: PathBuf::from("/wt"),
4511 branch: Some("feature".into()),
4512 remote: "origin".into(),
4513 remote_branch: "feature".into(),
4514 result,
4515 };
4516 let rendered = render_push_outcomes(&[
4517 outcome(PushResult::UpToDate),
4518 outcome(PushResult::WouldFastForward { ahead: 3 }),
4519 outcome(PushResult::WouldCreate),
4520 outcome(PushResult::Pushed { forced: true }),
4521 outcome(PushResult::Pushed { forced: false }),
4522 outcome(PushResult::Created),
4523 outcome(PushResult::Rejected {
4524 detail: "pre-receive hook declined".into(),
4525 stale: false,
4526 }),
4527 outcome(PushResult::Skipped {
4528 reason: SkipReason::DetachedHead,
4529 }),
4530 outcome(PushResult::Skipped {
4531 reason: SkipReason::NotAWorktree,
4532 }),
4533 outcome(PushResult::Skipped {
4534 reason: SkipReason::NoRemote,
4535 }),
4536 ]);
4537
4538 for expected in [
4539 "up-to-date",
4540 "3 ahead; fast-forward",
4541 "no upstream yet",
4542 "forced with lease",
4543 "fast-forward",
4544 "upstream set",
4545 "pre-receive hook declined",
4546 "detached HEAD",
4547 "not a git worktree",
4548 "no remote to publish to",
4549 ] {
4550 assert!(
4551 rendered.contains(expected),
4552 "missing {expected:?}: {rendered}"
4553 );
4554 }
4555 assert!(
4556 !rendered.contains("`git fetch` and rebase"),
4557 "only a *lease* refusal earns the fetch-and-rebase instruction: {rendered}"
4558 );
4559 }
4560
4561 #[test]
4562 fn push_rows_render_an_unresolved_destination_as_a_dash() {
4563 let rendered = render_push_outcomes(&[worktree_push::WorktreeOutcome {
4566 path: PathBuf::from("/wt"),
4567 branch: None,
4568 remote: String::new(),
4569 remote_branch: String::new(),
4570 result: worktree_push::PushResult::Skipped {
4571 reason: worktree_push::SkipReason::NotAWorktree,
4572 },
4573 }]);
4574 let row = rendered.lines().nth(1).unwrap();
4575 assert!(
4576 row.contains(" - "),
4577 "branch and remote both render as `-`: {row}"
4578 );
4579 }
4580
4581 #[test]
4582 fn push_all_selects_the_repository_rather_than_named_paths() {
4583 let cmd = PushCommand {
4584 all: true,
4585 ..push_cmd()
4586 };
4587 let selection = cmd.selection(Some(Path::new("/base"))).unwrap();
4588 match selection {
4589 Selection::All { base } => assert_eq!(base, PathBuf::from("/base")),
4590 other @ Selection::Paths(_) => panic!("expected an --all selection, got {other:?}"),
4591 }
4592 }
4593
4594 #[tokio::test]
4595 async fn push_json_output_carries_the_dry_run_flag_and_the_outcomes() {
4596 let (_root, _origin, wt) = push_scenario();
4599 let cmd = PushCommand {
4600 paths: vec![wt.clone()],
4601 dry_run: true,
4602 output: TableOrJson::Json,
4603 ..push_cmd()
4604 };
4605 cmd.execute_with(None, |_, _| async { false })
4608 .await
4609 .expect("a dry run must succeed");
4610 }
4611
4612 fn push_scenario() -> (tempfile::TempDir, PathBuf, PathBuf) {
4616 let _guard = crate::git::worktree_batch::test_serial_lock();
4619 let root = tempfile::tempdir().unwrap();
4620 let origin = root.path().join("origin.git");
4621 let local = root.path().join("local");
4622 let wt = root.path().join("feature-wt");
4623 std::fs::create_dir_all(&origin).unwrap();
4624 std::fs::create_dir_all(&local).unwrap();
4625
4626 let git = |dir: &Path, args: &[&str]| {
4627 let out = crate::git::worktree_batch::run_git_in(
4628 &crate::git::resolve_git_binary(),
4629 dir,
4630 args,
4631 )
4632 .unwrap();
4633 assert!(
4634 out.status.success(),
4635 "git {args:?} failed: {}",
4636 String::from_utf8_lossy(&out.stderr)
4637 );
4638 };
4639
4640 git(&origin, &["init", "--bare", "-b", "main"]);
4641 git(&local, &["init", "-b", "main"]);
4642 git(&local, &["config", "user.name", "Test"]);
4643 git(&local, &["config", "user.email", "test@example.com"]);
4644 git(&local, &["config", "commit.gpgsign", "false"]);
4645 std::fs::write(local.join("f.txt"), "base\n").unwrap();
4646 git(&local, &["add", "f.txt"]);
4647 git(&local, &["commit", "-m", "base"]);
4648 git(
4649 &local,
4650 &["remote", "add", "origin", origin.to_str().unwrap()],
4651 );
4652 git(&local, &["push", "-u", "origin", "main"]);
4653 git(
4654 &local,
4655 &[
4656 "worktree",
4657 "add",
4658 "-b",
4659 "feature",
4660 wt.to_str().unwrap(),
4661 "main",
4662 ],
4663 );
4664 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
4665 git(&wt, &["add", "g.txt"]);
4666 git(&wt, &["commit", "-m", "work"]);
4667 git(&wt, &["push", "-u", "origin", "feature"]);
4668 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
4669
4670 (root, origin, std::fs::canonicalize(&wt).unwrap())
4671 }
4672
4673 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
4675 git2::Repository::open_bare(origin)
4676 .unwrap()
4677 .refname_to_id(refname)
4678 .ok()
4679 }
4680}