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) -> Result<()> {
83 match self.command {
84 WorktreesSubcommands::List(cmd) => cmd.execute().await,
85 WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
86 WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
87 WorktreesSubcommands::Close(cmd) => cmd.execute().await,
88 WorktreesSubcommands::Rebase(cmd) => cmd.execute().await,
89 WorktreesSubcommands::Push(cmd) => cmd.execute().await,
90 WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
91 WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
92 WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
93 WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
94 WorktreesSubcommands::Register(cmd) => cmd.execute().await,
95 WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
96 WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
97 WorktreesSubcommands::Ui(cmd) => cmd.execute().await,
98 }
99 }
100}
101
102#[derive(Parser)]
104pub struct ListCommand {
105 #[arg(long, value_name = "PATH")]
107 pub socket: Option<PathBuf>,
108 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
110 pub output: TableOrJson,
111 #[arg(long, hide = true)]
113 pub json: bool,
114}
115
116impl ListCommand {
117 pub async fn execute(mut self) -> Result<()> {
119 if self.json {
120 eprintln!("warning: --json is deprecated; use -o/--output json instead");
121 self.output = TableOrJson::Json;
122 }
123 let socket = server::resolve_socket(self.socket)?;
124 let result = call(&socket, "list", Value::Null).await?;
125 match self.output {
126 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
127 TableOrJson::Table => println!("{}", render_windows(&result)),
128 }
129 Ok(())
130 }
131}
132
133#[derive(Parser)]
137pub struct TreeCommand {
138 #[arg(long, value_name = "PATH")]
140 pub socket: Option<PathBuf>,
141 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
143 pub output: TableOrJson,
144 #[arg(short = 'f', long)]
147 pub follow: bool,
148}
149
150impl TreeCommand {
151 pub async fn execute(self) -> Result<()> {
153 let socket = server::resolve_socket(self.socket)?;
154 if self.follow {
155 return follow_tree_stream(&socket, self.output).await;
156 }
157 let mut result = call(&socket, "tree", Value::Null).await?;
158 enrich_ahead_behind(&socket, &mut result).await;
164 match self.output {
165 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
166 TableOrJson::Table => println!("{}", render_tree(&result)),
167 }
168 Ok(())
169 }
170}
171
172async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
179 let mut sub = DaemonClient::new(socket)
180 .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
181 .await?;
182 loop {
183 tokio::select! {
184 frame = sub.next() => {
185 let Some(frame) = frame else { break };
187 let mut payload = reply_payload(frame?)?;
188 enrich_ahead_behind(socket, &mut payload).await;
192 match output {
193 TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
195 TableOrJson::Table => println!("{}", render_tree(&payload)),
196 }
197 }
198 _ = tokio::signal::ctrl_c() => break,
201 }
202 }
203 Ok(())
204}
205
206#[derive(Parser)]
213pub struct FocusCommand {
214 #[arg(value_name = "PATH")]
216 pub path: PathBuf,
217 #[arg(long, value_name = "PATH")]
219 pub socket: Option<PathBuf>,
220}
221
222impl FocusCommand {
223 pub async fn execute(self) -> Result<()> {
225 let path = std::fs::canonicalize(&self.path)
229 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
230 let socket = server::resolve_socket(self.socket)?;
231 call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
232 println!("Focused {}", path.display());
233 Ok(())
234 }
235}
236
237#[derive(Parser)]
246pub struct CloseCommand {
247 #[arg(value_name = "PATH")]
250 pub path: PathBuf,
251 #[arg(long)]
253 pub window_only: bool,
254 #[arg(long)]
256 pub dry_run: bool,
257 #[arg(short = 'y', long)]
259 pub yes: bool,
260 #[arg(long, value_name = "PATH")]
262 pub socket: Option<PathBuf>,
263}
264
265impl CloseCommand {
266 pub async fn execute(self) -> Result<()> {
268 self.execute_with(confirm_removal).await
269 }
270
271 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
276 where
277 F: FnOnce(bool) -> Fut,
278 Fut: std::future::Future<Output = bool>,
279 {
280 let path = std::fs::canonicalize(&self.path)
283 .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
284 let path_str = path.to_string_lossy().to_string();
285 let socket = server::resolve_socket(self.socket)?;
286
287 if self.window_only {
291 if self.dry_run {
292 println!(
293 "Would close the window for {} (dry run; nothing closed)",
294 path.display()
295 );
296 return Ok(());
297 }
298 call(
299 &socket,
300 "close",
301 json!({ "path": path_str, "remove": false }),
302 )
303 .await?;
304 println!("Closed the window for {}", path.display());
305 return Ok(());
306 }
307
308 let report = call(
310 &socket,
311 "close",
312 json!({ "path": path_str, "remove": true }),
313 )
314 .await?;
315 println!("{}", render_safety_report(&path, &report));
316
317 if self.dry_run {
318 return Ok(());
319 }
320 if report.get("removable").and_then(Value::as_bool) != Some(true) {
323 bail!(
324 "{} is not a removable worktree (nothing deleted); \
325 use --window-only to just close its window",
326 path.display()
327 );
328 }
329 let has_risks = report
330 .get("risks")
331 .and_then(Value::as_array)
332 .is_some_and(|r| !r.is_empty());
333 if !self.yes && !confirm(has_risks).await {
334 println!("Aborted; nothing was deleted.");
335 return Ok(());
336 }
337
338 call(
340 &socket,
341 "close",
342 json!({ "path": path_str, "remove": true, "confirmed": true }),
343 )
344 .await?;
345 println!("Deleted worktree {}", path.display());
346 Ok(())
347 }
348}
349
350#[derive(Parser)]
364pub struct RebaseCommand {
365 #[arg(value_name = "PATH")]
368 pub paths: Vec<PathBuf>,
369 #[arg(long)]
372 pub all: bool,
373 #[arg(long, value_name = "REF")]
376 pub onto: Option<String>,
377 #[arg(long)]
380 pub autostash: bool,
381 #[arg(long)]
383 pub dry_run: bool,
384 #[arg(long)]
387 pub keep_conflicts: bool,
388 #[arg(short = 'y', long)]
390 pub yes: bool,
391 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
393 pub output: TableOrJson,
394 #[command(flatten)]
396 pub repo: crate::cli::repo_arg::RepoArg,
397}
398
399impl RebaseCommand {
400 pub async fn execute(self) -> Result<()> {
405 let repo = self.repo.repo.clone();
406 self.execute_with(repo.as_deref(), confirm_rebase).await
407 }
408
409 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
414 where
415 F: FnOnce(usize) -> Fut,
416 Fut: std::future::Future<Output = bool>,
417 {
418 let selection = self.selection(repo)?;
419 let opts = RebaseOptions {
420 onto: self.onto.clone(),
421 autostash: self.autostash,
422 dry_run: self.dry_run,
423 keep_conflicts: self.keep_conflicts,
424 git_bin: None,
428 };
429
430 let plan_opts = opts.clone();
433 let plan =
434 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
435 .await
436 .context("rebase planning task panicked")??;
437
438 let json = matches!(self.output, TableOrJson::Json);
439 if self.dry_run || !plan.has_pending_rebases() {
443 self.print(json, &plan.fetches, &plan.worktrees)?;
444 return Ok(());
445 }
446
447 if !json {
449 println!("{}", render_fetches(&plan.fetches));
450 println!("{}", render_outcomes(&plan.worktrees));
451 }
452 let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
453 if !self.yes && !confirm(pending).await {
454 println!("Aborted; no worktree was rebased.");
455 return Ok(());
456 }
457
458 let fetches = plan.fetches.clone();
459 let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
460 .await
461 .context("rebase task panicked")?;
462 if !json {
463 println!();
464 }
465 self.print(json, &fetches, &outcomes)
466 }
467
468 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
476 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
477 if self.all {
478 if !self.paths.is_empty() {
479 bail!("pass either <PATH>... or --all, not both");
480 }
481 return Ok(Selection::All { base });
482 }
483 if self.paths.is_empty() {
484 bail!(
485 "specify one or more <PATH> arguments, or --all to rebase \
486 every worktree of this repository"
487 );
488 }
489 let paths = self
490 .paths
491 .iter()
492 .map(|path| {
493 if path.is_absolute() {
494 path.clone()
495 } else {
496 base.join(path)
497 }
498 })
499 .collect();
500 Ok(Selection::Paths(paths))
501 }
502
503 fn print(
505 &self,
506 json: bool,
507 fetches: &[FetchOutcome],
508 outcomes: &[WorktreeOutcome],
509 ) -> Result<()> {
510 if json {
511 let value = json!({
512 "dry_run": self.dry_run,
513 "fetches": fetches,
514 "worktrees": outcomes,
515 });
516 println!("{}", serde_json::to_string_pretty(&value)?);
517 } else {
518 println!("{}", render_fetches(fetches));
519 println!("{}", render_outcomes(outcomes));
520 }
521 Ok(())
522 }
523}
524
525fn is_pending(outcome: &WorktreeOutcome) -> bool {
527 matches!(outcome.result, RebaseResult::WouldRebase { .. })
528}
529
530fn render_fetches(fetches: &[FetchOutcome]) -> String {
533 if fetches.is_empty() {
534 return "No repository selected.".to_string();
535 }
536 fetches
537 .iter()
538 .map(fetch_line)
539 .collect::<Vec<_>>()
540 .join("\n")
541}
542
543fn fetch_line(fetch: &FetchOutcome) -> String {
545 let root = sanitize(&fetch.repo_root.display().to_string());
546 let onto = sanitize(&fetch.onto);
547 if !fetch.fetched {
548 return format!("Using {onto} in {root} (local ref; nothing fetched)");
549 }
550 if fetch.ok {
551 format!("Fetched {onto} once for {root}")
552 } else {
553 let detail = brief(fetch.detail.as_deref().unwrap_or(""));
554 format!("Fetch of {onto} FAILED for {root}: {detail}")
555 }
556}
557
558fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
560 if outcomes.is_empty() {
561 return "No worktrees selected.".to_string();
562 }
563 let mut out = format!(
564 "{:<12} {:<24} {:<16} {}",
565 "STATUS", "BRANCH", "ONTO", "WORKTREE"
566 );
567 for outcome in outcomes {
568 out.push('\n');
569 out.push_str(&outcome_row(outcome));
570 }
571 out
572}
573
574fn outcome_row(outcome: &WorktreeOutcome) -> String {
576 let (status, detail) = status_and_detail(&outcome.result);
577 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
578 let onto = sanitize(&outcome.onto);
579 let path = sanitize(&outcome.path.display().to_string());
580 let suffix = if detail.is_empty() {
581 String::new()
582 } else {
583 format!(" ({detail})")
584 };
585 format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
586}
587
588fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
590 match result {
591 RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
592 RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
593 RebaseResult::UpToDate => ("up-to-date", String::new()),
594 RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
595 RebaseResult::Conflict {
599 detail,
600 left_in_place: true,
601 } => (
602 "conflict",
603 format!(
604 "left in place; resolve then `git rebase --continue`: {}",
605 brief(detail)
606 ),
607 ),
608 RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
609 RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
610 }
611}
612
613fn skip_reason_text(reason: SkipReason) -> &'static str {
615 match reason {
616 SkipReason::DetachedHead => "detached HEAD",
617 SkipReason::Dirty => "uncommitted changes; pass --autostash",
618 SkipReason::OperationInProgress => "a rebase/merge is already in progress",
619 SkipReason::NotAWorktree => "not a git worktree",
620 SkipReason::NoOntoRef => "could not resolve the target ref",
621 }
622}
623
624fn brief(detail: &str) -> String {
627 let first = detail
628 .lines()
629 .find(|line| !line.trim().is_empty())
630 .unwrap_or("");
631 let clean = sanitize(first.trim());
632 if clean.chars().count() > 100 {
633 let truncated: String = clean.chars().take(97).collect();
634 format!("{truncated}...")
635 } else {
636 clean
637 }
638}
639
640async fn confirm_rebase(pending: usize) -> bool {
642 confirm_rebase_with(pending, read_stdin_line()).await
643}
644
645async fn confirm_rebase_with(
649 pending: usize,
650 read: impl std::future::Future<Output = Option<String>>,
651) -> bool {
652 use std::io::Write;
653 eprint!("{}", rebase_prompt(pending));
654 let _ = std::io::stderr().flush();
655 read.await.as_deref().is_some_and(answer_is_yes)
656}
657
658fn rebase_prompt(pending: usize) -> String {
661 let noun = if pending == 1 {
662 "worktree"
663 } else {
664 "worktrees"
665 };
666 format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
667}
668
669#[derive(Parser)]
689pub struct PushCommand {
690 #[arg(value_name = "PATH")]
693 pub paths: Vec<PathBuf>,
694 #[arg(long)]
697 pub all: bool,
698 #[arg(long)]
700 pub dry_run: bool,
701 #[arg(short = 'y', long)]
703 pub yes: bool,
704 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
706 pub output: TableOrJson,
707 #[command(flatten)]
709 pub repo: crate::cli::repo_arg::RepoArg,
710}
711
712impl PushCommand {
713 pub async fn execute(self) -> Result<()> {
718 let repo = self.repo.repo.clone();
719 self.execute_with(repo.as_deref(), confirm_push).await
720 }
721
722 async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
727 where
728 F: FnOnce(usize, usize) -> Fut,
729 Fut: std::future::Future<Output = bool>,
730 {
731 let selection = self.selection(repo)?;
732
733 let plan = tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
738 .await
739 .context("push planning task panicked")??;
740
741 let json = matches!(self.output, TableOrJson::Json);
742 if self.dry_run || !plan.has_pending_pushes() {
744 return self.print(json, &plan.worktrees);
745 }
746
747 if !json {
750 println!("{}", render_push_outcomes(&plan.worktrees));
751 }
752 let pending = plan
753 .worktrees
754 .iter()
755 .filter(|w| w.result.is_pending())
756 .count();
757 let forced = plan
758 .worktrees
759 .iter()
760 .filter(|w| matches!(w.result, worktree_push::PushResult::WouldForce { .. }))
761 .count();
762 if !self.yes && !confirm(pending, forced).await {
763 println!("Aborted; nothing was pushed.");
764 return Ok(());
765 }
766
767 let opts = worktree_push::PushOptions::default();
771 let outcomes = tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts))
772 .await
773 .context("push task panicked")?;
774 if !json {
775 println!();
776 }
777 self.print(json, &outcomes)
778 }
779
780 fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
783 let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
784 if self.all {
785 if !self.paths.is_empty() {
786 bail!("pass either <PATH>... or --all, not both");
787 }
788 return Ok(Selection::All { base });
789 }
790 if self.paths.is_empty() {
791 bail!(
792 "specify one or more <PATH> arguments, or --all to publish \
793 every worktree of this repository"
794 );
795 }
796 let paths = self
797 .paths
798 .iter()
799 .map(|path| {
800 if path.is_absolute() {
801 path.clone()
802 } else {
803 base.join(path)
804 }
805 })
806 .collect();
807 Ok(Selection::Paths(paths))
808 }
809
810 fn print(&self, json: bool, outcomes: &[worktree_push::WorktreeOutcome]) -> Result<()> {
812 if json {
813 let value = json!({ "dry_run": self.dry_run, "worktrees": outcomes });
814 println!("{}", serde_json::to_string_pretty(&value)?);
815 } else {
816 println!("{}", render_push_outcomes(outcomes));
817 }
818 Ok(())
819 }
820}
821
822fn render_push_outcomes(outcomes: &[worktree_push::WorktreeOutcome]) -> String {
824 if outcomes.is_empty() {
825 return "No worktrees selected.".to_string();
826 }
827 let mut out = format!(
828 "{:<14} {:<24} {:<20} {}",
829 "STATUS", "BRANCH", "REMOTE", "WORKTREE"
830 );
831 for outcome in outcomes {
832 out.push('\n');
833 out.push_str(&push_outcome_row(outcome));
834 }
835 out
836}
837
838fn push_outcome_row(outcome: &worktree_push::WorktreeOutcome) -> String {
840 let (status, detail) = push_status_and_detail(&outcome.result);
841 let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
842 let destination = if outcome.remote.is_empty() {
843 "-".to_string()
844 } else {
845 sanitize(&format!("{}/{}", outcome.remote, outcome.remote_branch))
846 };
847 let path = sanitize(&outcome.path.display().to_string());
848 let suffix = if detail.is_empty() {
849 String::new()
850 } else {
851 format!(" ({detail})")
852 };
853 format!("{status:<14} {branch:<24} {destination:<20} {path}{suffix}")
854}
855
856fn push_status_and_detail(result: &worktree_push::PushResult) -> (&'static str, String) {
858 use worktree_push::PushResult;
859 match result {
860 PushResult::UpToDate => ("up-to-date", String::new()),
861 PushResult::WouldFastForward { ahead } => {
862 ("would-push", format!("{ahead} ahead; fast-forward"))
863 }
864 PushResult::WouldForce { ahead, behind } => (
865 "would-force",
866 format!("{ahead} ahead, {behind} behind; needs --force-with-lease"),
867 ),
868 PushResult::WouldCreate => ("would-create", "no upstream yet".to_string()),
869 PushResult::Pushed { forced: true } => ("pushed", "forced with lease".to_string()),
870 PushResult::Pushed { forced: false } => ("pushed", "fast-forward".to_string()),
871 PushResult::Created => ("created", "upstream set".to_string()),
872 PushResult::Rejected { detail, stale: true } => (
876 "rejected",
877 format!(
878 "the remote moved since you last fetched; run `git fetch` and rebase, then retry: {}",
879 brief(detail)
880 ),
881 ),
882 PushResult::Rejected { detail, .. } => ("rejected", brief(detail)),
883 PushResult::Skipped { reason } => ("skipped", push_skip_reason_text(*reason).to_string()),
884 }
885}
886
887fn push_skip_reason_text(reason: worktree_push::SkipReason) -> &'static str {
889 use worktree_push::SkipReason;
890 match reason {
891 SkipReason::DetachedHead => "detached HEAD",
892 SkipReason::NotAWorktree => "not a git worktree",
893 SkipReason::NoRemote => "no remote to publish to",
894 SkipReason::DefaultBranchForcePush => {
895 "refusing to force-push the remote default branch; \
896 fast-forward it or open a PR instead"
897 }
898 }
899}
900
901async fn confirm_push(pending: usize, forced: usize) -> bool {
903 confirm_push_with(pending, forced, read_stdin_line()).await
904}
905
906async fn confirm_push_with(
910 pending: usize,
911 forced: usize,
912 read: impl std::future::Future<Output = Option<String>>,
913) -> bool {
914 use std::io::Write;
915 eprint!("{}", push_prompt(pending, forced));
916 let _ = std::io::stderr().flush();
917 read.await.as_deref().is_some_and(answer_is_yes)
918}
919
920fn push_prompt(pending: usize, forced: usize) -> String {
926 let noun = if pending == 1 { "branch" } else { "branches" };
927 if forced == 0 {
928 return format!("Push {pending} {noun}? [y/N] ");
929 }
930 let forced_noun = if forced == 1 { "one" } else { "them" };
931 format!(
932 "Push {pending} {noun}, force-pushing {forced} with a lease \
933 (this publishes rewritten history — anyone who has {forced_noun} \
934 will need to reset)? [y/N] "
935 )
936}
937
938#[derive(Parser)]
947pub struct MergeQueueCommand {
948 #[arg(value_name = "PATH", required = true)]
951 pub paths: Vec<PathBuf>,
952 #[arg(long)]
954 pub check: bool,
955 #[arg(short = 'y', long)]
957 pub yes: bool,
958 #[arg(long, value_name = "PATH")]
960 pub socket: Option<PathBuf>,
961}
962
963impl MergeQueueCommand {
964 pub async fn execute(self) -> Result<()> {
967 self.execute_with(confirm_enqueue).await
968 }
969
970 async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
975 where
976 F: FnOnce(usize) -> Fut,
977 Fut: std::future::Future<Output = bool>,
978 {
979 let mut paths = Vec::with_capacity(self.paths.len());
982 for p in &self.paths {
983 let abs = std::fs::canonicalize(p)
984 .with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
985 paths.push(abs.to_string_lossy().to_string());
986 }
987 let socket = server::resolve_socket(self.socket)?;
988
989 let report = call(
991 &socket,
992 "merge-queue",
993 json!({ "paths": paths, "check": true }),
994 )
995 .await?;
996 println!("{}", render_eligibility_report(&report));
997
998 if self.check {
999 return Ok(());
1000 }
1001 let eligible = report
1002 .get("eligible")
1003 .and_then(Value::as_array)
1004 .map_or(0, Vec::len);
1005 if eligible == 0 {
1006 println!("Nothing to enqueue.");
1007 return Ok(());
1008 }
1009 if !self.yes && !confirm(eligible).await {
1010 println!("Aborted; nothing was enqueued.");
1011 return Ok(());
1012 }
1013
1014 let result = call(
1016 &socket,
1017 "merge-queue",
1018 json!({ "paths": paths, "confirmed": true }),
1019 )
1020 .await?;
1021 println!("{}", render_enqueue_result(&result));
1022 Ok(())
1023 }
1024}
1025
1026#[derive(Parser)]
1039pub struct RepositionCommand {
1040 #[arg(value_name = "PATH")]
1043 pub paths: Vec<PathBuf>,
1044 #[arg(
1047 long,
1048 value_name = "PATH",
1049 required_unless_present = "undo",
1050 conflicts_with = "undo"
1051 )]
1052 pub reference: Option<PathBuf>,
1053 #[arg(long, conflicts_with = "undo")]
1055 pub dry_run: bool,
1056 #[arg(long)]
1058 pub undo: bool,
1059 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1061 pub output: TableOrJson,
1062 #[arg(long, value_name = "PATH")]
1064 pub socket: Option<PathBuf>,
1065}
1066
1067impl RepositionCommand {
1068 pub async fn execute(self) -> Result<()> {
1070 let output = self.output;
1071 let socket = server::resolve_socket(self.socket)?;
1072 if self.undo {
1073 let reply = call(&socket, "reposition-undo", Value::Null).await?;
1074 return print_reposition(output, &reply);
1075 }
1076 let Some(reference) = self.reference.as_deref() else {
1078 bail!("`reposition` requires `--reference <PATH>`");
1079 };
1080
1081 let windows = call(&socket, "list", Value::Null).await?;
1085 let reference_key = window_key_for(&windows, reference, "repositioned")?;
1086 let mut target_keys = Vec::with_capacity(self.paths.len());
1087 for path in &self.paths {
1088 target_keys.push(window_key_for(&windows, path, "repositioned")?);
1089 }
1090
1091 let reply = call(
1092 &socket,
1093 "reposition",
1094 json!({
1095 "reference_key": reference_key,
1096 "target_keys": target_keys,
1097 "check": self.dry_run,
1098 }),
1099 )
1100 .await?;
1101 print_reposition(output, &reply)
1102 }
1103}
1104
1105fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
1107 match output {
1108 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
1109 TableOrJson::Table => println!("{}", render_reposition(reply)),
1110 }
1111 Ok(())
1112}
1113
1114#[derive(Parser)]
1129pub struct ReloadCommand {
1130 #[arg(value_name = "PATH", required = true)]
1134 pub paths: Vec<PathBuf>,
1135 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1137 pub output: TableOrJson,
1138 #[arg(long, value_name = "PATH")]
1140 pub socket: Option<PathBuf>,
1141}
1142
1143impl ReloadCommand {
1144 pub async fn execute(self) -> Result<()> {
1146 let socket = server::resolve_socket(self.socket)?;
1147 let windows = call(&socket, "list", Value::Null).await?;
1148 let mut target_keys = Vec::with_capacity(self.paths.len());
1149 for path in &self.paths {
1150 target_keys.push(window_key_for(&windows, path, "reloaded")?);
1151 }
1152
1153 let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
1154 match self.output {
1155 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
1156 TableOrJson::Table => println!("{}", render_reload(&reply)),
1157 }
1158 Ok(())
1159 }
1160}
1161
1162fn render_reload(reply: &Value) -> String {
1169 let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
1170 let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
1171 let unknown: Vec<String> = reply
1172 .get("unknown")
1173 .and_then(Value::as_array)
1174 .map(|keys| {
1175 keys.iter()
1176 .filter_map(Value::as_str)
1177 .map(sanitize)
1178 .collect()
1179 })
1180 .unwrap_or_default();
1181
1182 let noun = if requested == 1 { "window" } else { "windows" };
1185 let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
1186 if !unknown.is_empty() {
1187 out.push_str(&format!(
1190 "\nNo longer open, so not signalled: {}",
1191 unknown.join(", ")
1192 ));
1193 }
1194 out
1195}
1196
1197fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
1208 let wanted = std::fs::canonicalize(path)
1209 .with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
1210 windows
1211 .get("windows")
1212 .and_then(Value::as_array)
1213 .map(Vec::as_slice)
1214 .unwrap_or_default()
1215 .iter()
1216 .find(|window| {
1217 window
1218 .get("folders")
1219 .and_then(Value::as_array)
1220 .is_some_and(|folders| {
1221 folders.iter().filter_map(Value::as_str).any(|folder| {
1222 std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
1223 })
1224 })
1225 })
1226 .and_then(|window| window.get("key").and_then(Value::as_str))
1227 .map(ToString::to_string)
1228 .ok_or_else(|| {
1229 anyhow::anyhow!(
1230 "no VS Code window has {} open (only open windows can be {verb})",
1231 wanted.display()
1232 )
1233 })
1234}
1235
1236fn render_reposition(reply: &Value) -> String {
1240 if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
1241 return "omni-dev does not hold the macOS Accessibility permission, so no window \
1242 was touched.\nGrant it in System Settings → Privacy & Security → \
1243 Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
1244 .to_string();
1245 }
1246 if let Some(blocked) = reply.get("blocked") {
1247 let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
1248 let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
1249 return format!("Nothing was moved [{reason}]: {detail}");
1250 }
1251
1252 let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
1253 let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
1254 let mut out = String::new();
1255 if let Some(reference) = reply.get("reference") {
1256 let title = sanitize(
1257 reference
1258 .get("title")
1259 .and_then(Value::as_str)
1260 .unwrap_or("-"),
1261 );
1262 out.push_str(&format!(
1263 "Reference: {title} {}\n",
1264 render_frame(reference.get("frame"))
1265 ));
1266 }
1267 out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
1268 let results = reply
1269 .get("results")
1270 .and_then(Value::as_array)
1271 .map(Vec::as_slice)
1272 .unwrap_or_default();
1273 for result in results {
1274 let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
1275 let title = sanitize(
1276 result
1277 .get("title")
1278 .and_then(Value::as_str)
1279 .or_else(|| result.get("key").and_then(Value::as_str))
1280 .unwrap_or("-"),
1281 );
1282 let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
1283 out.push_str(&format!("\n {outcome}: {title} — {detail}"));
1284 }
1285 if results.is_empty() {
1286 out.push_str("\n (nothing to report)");
1287 }
1288 out
1289}
1290
1291fn render_frame(frame: Option<&Value>) -> String {
1293 let Some(frame) = frame else {
1294 return "-".to_string();
1295 };
1296 let field = |name: &str| {
1297 frame
1298 .get(name)
1299 .and_then(Value::as_f64)
1300 .unwrap_or(0.0)
1301 .round()
1302 };
1303 format!(
1304 "{}×{} at ({}, {})",
1305 field("width"),
1306 field("height"),
1307 field("x"),
1308 field("y")
1309 )
1310}
1311
1312#[derive(Parser)]
1318pub struct ShowClosedCommand {
1319 #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
1321 pub value: Option<bool>,
1322 #[arg(long, value_name = "PATH")]
1324 pub socket: Option<PathBuf>,
1325}
1326
1327impl ShowClosedCommand {
1328 pub async fn execute(self) -> Result<()> {
1330 let socket = server::resolve_socket(self.socket)?;
1331 if let Some(show_closed) = self.value {
1332 call(
1333 &socket,
1334 "set-show-closed",
1335 json!({ "show_closed": show_closed }),
1336 )
1337 .await?;
1338 println!("show-closed: {show_closed}");
1339 } else {
1340 let tree = call(&socket, "tree", Value::Null).await?;
1342 let current = tree
1343 .get("show_closed")
1344 .and_then(Value::as_bool)
1345 .unwrap_or(true);
1346 println!("show-closed: {current}");
1347 }
1348 Ok(())
1349 }
1350}
1351
1352#[derive(Parser)]
1358pub struct RegisterCommand {
1359 #[arg(long, value_name = "KEY")]
1361 pub key: String,
1362 #[arg(long = "folder", value_name = "PATH")]
1364 pub folders: Vec<PathBuf>,
1365 #[arg(long, value_name = "REPO")]
1373 pub repo_name: Option<String>,
1374 #[arg(long, value_name = "TITLE")]
1376 pub title: Option<String>,
1377 #[arg(long, value_name = "PID")]
1379 pub pid: Option<u32>,
1380 #[arg(long, value_name = "PATH")]
1382 pub socket: Option<PathBuf>,
1383}
1384
1385impl RegisterCommand {
1386 pub async fn execute(self) -> Result<()> {
1388 let socket = server::resolve_socket(self.socket)?;
1389 let payload = json!({
1390 "key": self.key,
1391 "folders": self.folders,
1392 "repo": self.repo_name,
1393 "title": self.title,
1394 "pid": self.pid,
1395 });
1396 call(&socket, "register", payload).await?;
1397 println!("Registered {}", self.key);
1398 Ok(())
1399 }
1400}
1401
1402#[derive(Parser)]
1409pub struct HeartbeatCommand {
1410 #[arg(long, value_name = "KEY")]
1412 pub key: String,
1413 #[arg(long, value_name = "PATH")]
1415 pub socket: Option<PathBuf>,
1416}
1417
1418impl HeartbeatCommand {
1419 pub async fn execute(self) -> Result<()> {
1421 let socket = server::resolve_socket(self.socket)?;
1422 let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
1423 let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
1424 let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
1427 let reload = reply
1428 .get("reload")
1429 .and_then(Value::as_bool)
1430 .unwrap_or(false);
1431 println!("known: {known}");
1432 println!("close: {close}");
1433 println!("reload: {reload}");
1434 Ok(())
1435 }
1436}
1437
1438#[derive(Parser)]
1441pub struct UnregisterCommand {
1442 #[arg(long, value_name = "KEY")]
1444 pub key: String,
1445 #[arg(long, value_name = "PATH")]
1447 pub socket: Option<PathBuf>,
1448}
1449
1450impl UnregisterCommand {
1451 pub async fn execute(self) -> Result<()> {
1453 let socket = server::resolve_socket(self.socket)?;
1454 let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
1455 let removed = reply
1456 .get("removed")
1457 .and_then(Value::as_bool)
1458 .unwrap_or(false);
1459 println!("removed: {removed}");
1460 Ok(())
1461 }
1462}
1463
1464fn render_safety_report(path: &Path, report: &Value) -> String {
1469 let removable = report
1470 .get("removable")
1471 .and_then(Value::as_bool)
1472 .unwrap_or(false);
1473 let is_main = report
1474 .get("is_main")
1475 .and_then(Value::as_bool)
1476 .unwrap_or(false);
1477 let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
1478 let mut out = format!("Worktree: {}", path.display());
1479 out.push_str(&format!("\n removable: {removable}"));
1480 out.push_str(&format!("\n main working tree: {is_main}"));
1481 if open {
1482 let key = sanitize(
1483 report
1484 .get("window_key")
1485 .and_then(Value::as_str)
1486 .unwrap_or("-"),
1487 );
1488 let count = report
1489 .get("window_folder_count")
1490 .and_then(Value::as_u64)
1491 .unwrap_or(0);
1492 out.push_str(&format!(
1493 "\n open in a window: yes (key {key}, {count} folder(s))"
1494 ));
1495 } else {
1496 out.push_str("\n open in a window: no");
1497 }
1498 out.push_str(&render_notes("risks", report.get("risks")));
1499 out.push_str(&render_notes("info", report.get("info")));
1500 out
1501}
1502
1503fn render_notes(label: &str, notes: Option<&Value>) -> String {
1506 let notes = notes
1507 .and_then(Value::as_array)
1508 .map(Vec::as_slice)
1509 .unwrap_or_default();
1510 if notes.is_empty() {
1511 return String::new();
1512 }
1513 let mut out = format!("\n {label}:");
1514 for note in notes {
1515 let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
1516 let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
1517 out.push_str(&format!("\n - [{kind}] {detail}"));
1518 }
1519 out
1520}
1521
1522fn render_eligibility_report(report: &Value) -> String {
1527 let eligible = report
1528 .get("eligible")
1529 .and_then(Value::as_array)
1530 .map(Vec::as_slice)
1531 .unwrap_or_default();
1532 let skipped = report
1533 .get("skipped")
1534 .and_then(Value::as_array)
1535 .map(Vec::as_slice)
1536 .unwrap_or_default();
1537 let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
1538 for pr in eligible {
1539 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1540 let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
1541 let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
1542 out.push_str(&format!("\n eligible: PR #{number} [{branch}] {path}"));
1543 }
1544 for skip in skipped {
1545 let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
1546 let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
1547 let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
1548 out.push_str(&format!("\n skipped [{kind}]: {path} — {detail}"));
1549 }
1550 out
1551}
1552
1553fn render_enqueue_result(result: &Value) -> String {
1558 let queued = result
1559 .get("queued")
1560 .and_then(Value::as_array)
1561 .map(Vec::as_slice)
1562 .unwrap_or_default();
1563 let failed = result
1564 .get("failed")
1565 .and_then(Value::as_array)
1566 .map(Vec::as_slice)
1567 .unwrap_or_default();
1568 let skipped = result
1569 .get("skipped")
1570 .and_then(Value::as_array)
1571 .map_or(0, Vec::len);
1572 let mut out = format!(
1573 "Queued: {} / Failed: {} / Skipped: {}",
1574 queued.len(),
1575 failed.len(),
1576 skipped
1577 );
1578 for pr in queued {
1579 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1580 let already = pr
1581 .get("already_queued")
1582 .and_then(Value::as_bool)
1583 .unwrap_or(false);
1584 let suffix = if already { " (already queued)" } else { "" };
1585 out.push_str(&format!("\n queued: PR #{number}{suffix}"));
1586 }
1587 for pr in failed {
1588 let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1589 let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
1590 out.push_str(&format!("\n failed: PR #{number} — {error}"));
1591 }
1592 out
1593}
1594
1595async fn confirm_removal(has_risks: bool) -> bool {
1602 confirm_removal_with(has_risks, read_stdin_line()).await
1603}
1604
1605async fn confirm_removal_with(
1610 has_risks: bool,
1611 read: impl std::future::Future<Output = Option<String>>,
1612) -> bool {
1613 use std::io::Write;
1614 eprint!("{}", confirm_prompt(has_risks));
1615 let _ = std::io::stderr().flush();
1616 read.await.as_deref().is_some_and(answer_is_yes)
1617}
1618
1619async fn confirm_enqueue(count: usize) -> bool {
1623 confirm_enqueue_with(count, read_stdin_line()).await
1624}
1625
1626async fn confirm_enqueue_with(
1630 count: usize,
1631 read: impl std::future::Future<Output = Option<String>>,
1632) -> bool {
1633 use std::io::Write;
1634 eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
1635 let _ = std::io::stderr().flush();
1636 read.await.as_deref().is_some_and(answer_is_yes)
1637}
1638
1639async fn read_stdin_line() -> Option<String> {
1643 tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
1644 .await
1645 .ok()
1646 .flatten()
1647}
1648
1649fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
1654 let mut answer = String::new();
1655 reader.read_line(&mut answer).ok().map(|_| answer)
1656}
1657
1658fn confirm_prompt(has_risks: bool) -> &'static str {
1661 if has_risks {
1662 "Delete this worktree despite the risks above? [y/N] "
1663 } else {
1664 "Delete this worktree? [y/N] "
1665 }
1666}
1667
1668fn answer_is_yes(answer: &str) -> bool {
1671 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
1672}
1673
1674async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
1681 let paths = worktree_paths(result);
1682 if paths.is_empty() {
1683 return;
1684 }
1685 let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
1686 return;
1687 };
1688 if let Some(results) = reply.get("results").and_then(Value::as_object) {
1689 merge_ahead_behind(result, results);
1690 }
1691}
1692
1693fn worktree_paths(result: &Value) -> Vec<String> {
1696 let mut paths = Vec::new();
1697 for repo in result
1698 .get("repos")
1699 .and_then(Value::as_array)
1700 .map(Vec::as_slice)
1701 .unwrap_or_default()
1702 {
1703 for worktree in repo
1704 .get("worktrees")
1705 .and_then(Value::as_array)
1706 .map(Vec::as_slice)
1707 .unwrap_or_default()
1708 {
1709 if let Some(path) = worktree.get("path").and_then(Value::as_str) {
1710 paths.push(path.to_string());
1711 }
1712 }
1713 }
1714 paths
1715}
1716
1717fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
1722 for repo in result
1723 .get_mut("repos")
1724 .and_then(Value::as_array_mut)
1725 .into_iter()
1726 .flatten()
1727 {
1728 for worktree in repo
1729 .get_mut("worktrees")
1730 .and_then(Value::as_array_mut)
1731 .into_iter()
1732 .flatten()
1733 {
1734 let Some(obj) = worktree.as_object_mut() else {
1738 continue;
1739 };
1740 let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
1741 continue;
1742 };
1743 let Some(counts) = results.get(&path) else {
1744 continue;
1745 };
1746 if let (Some(ahead), Some(behind)) =
1749 (counts.get("ahead").cloned(), counts.get("behind").cloned())
1750 {
1751 obj.insert("ahead".to_string(), ahead);
1752 obj.insert("behind".to_string(), behind);
1753 }
1754 if let Some(main_behind) = counts.get("main_behind").cloned() {
1759 obj.insert("main_behind".to_string(), main_behind);
1760 }
1761 }
1762 }
1763}
1764
1765async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1768 let reply = DaemonClient::new(socket)
1769 .request(DaemonEnvelope::service(SERVICE, op, payload))
1770 .await?;
1771 reply_payload(reply)
1772}
1773
1774fn reply_payload(reply: DaemonReply) -> Result<Value> {
1777 if reply.ok {
1778 Ok(reply.payload)
1779 } else {
1780 bail!(
1781 "daemon returned an error: {}",
1782 reply.error.as_deref().unwrap_or("unknown error")
1783 )
1784 }
1785}
1786
1787fn render_windows(result: &Value) -> String {
1792 let windows = result
1793 .get("windows")
1794 .and_then(Value::as_array)
1795 .map(Vec::as_slice)
1796 .unwrap_or_default();
1797 if windows.is_empty() {
1798 return "No open windows.".to_string();
1799 }
1800 let mut out = format!(
1801 "{:<22} {:<24} {:<9} {:<40} {:>5}",
1802 "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
1803 );
1804 for window in windows {
1805 let repo = sanitize(repo_name(window));
1806 let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
1807 let sync = sync_summary(window);
1808 let folder_disp = folder_summary(window);
1809 let age = age_secs(window.get("last_seen").and_then(Value::as_str));
1810 out.push_str(&format!(
1811 "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
1812 ));
1813 }
1814 out
1815}
1816
1817fn render_tree(result: &Value) -> String {
1823 let repos = result
1824 .get("repos")
1825 .and_then(Value::as_array)
1826 .map(Vec::as_slice)
1827 .unwrap_or_default();
1828 if repos.is_empty() {
1829 return "No repositories open.".to_string();
1830 }
1831 let mut out = String::new();
1832 for (i, repo) in repos.iter().enumerate() {
1833 if i > 0 {
1836 out.push_str("\n\n");
1837 }
1838 out.push_str(&repo_header(repo));
1839 for worktree in repo
1840 .get("worktrees")
1841 .and_then(Value::as_array)
1842 .map(Vec::as_slice)
1843 .unwrap_or_default()
1844 {
1845 out.push('\n');
1846 out.push_str(&worktree_row(worktree));
1847 }
1848 }
1849 out
1850}
1851
1852fn repo_header(repo: &Value) -> String {
1855 let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
1856 let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
1857 match github_summary(repo) {
1858 Some(github) => format!("{name} ({github}) {root}"),
1859 None => format!("{name} {root}"),
1860 }
1861}
1862
1863fn github_summary(repo: &Value) -> Option<String> {
1866 let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
1867 let name = repo.pointer("/github/name").and_then(Value::as_str)?;
1868 Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
1869}
1870
1871fn worktree_row(worktree: &Value) -> String {
1875 let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
1876 '*'
1877 } else {
1878 ' '
1879 };
1880 let branch = sanitize(
1881 worktree
1882 .get("branch")
1883 .and_then(Value::as_str)
1884 .unwrap_or("-"),
1885 );
1886 let sync = sync_summary(worktree);
1887 let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
1888 "open"
1889 } else {
1890 ""
1891 };
1892 let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
1893 format!(" {marker} {branch:<24} {sync:<16} {open:<5} {path}")
1894}
1895
1896fn repo_name(window: &Value) -> &str {
1900 window
1901 .get("main_repo")
1902 .and_then(Value::as_str)
1903 .or_else(|| window.get("repo").and_then(Value::as_str))
1904 .unwrap_or("-")
1905}
1906
1907fn sync_summary(window: &Value) -> String {
1914 let ahead = window.get("ahead").and_then(Value::as_u64);
1915 let behind = window.get("behind").and_then(Value::as_u64);
1916 let base = match (ahead, behind) {
1917 (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
1918 _ => "-".to_string(),
1919 };
1920 match window.get("main_behind").and_then(Value::as_u64) {
1921 Some(main_behind) => format!("{base} main-{main_behind}"),
1922 None => base,
1923 }
1924}
1925
1926fn folder_summary(window: &Value) -> String {
1929 let folders = window
1930 .get("folders")
1931 .and_then(Value::as_array)
1932 .map(Vec::as_slice)
1933 .unwrap_or_default();
1934 let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
1935 let extra = folders.len().saturating_sub(1);
1936 if extra > 0 {
1937 format!("{first} (+{extra})")
1938 } else {
1939 first
1940 }
1941}
1942
1943fn sanitize(s: &str) -> String {
1947 sanitize_for_terminal(s)
1948}
1949
1950fn age_secs(ts: Option<&str>) -> i64 {
1952 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1953 .map_or(0, |t| {
1954 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1955 })
1956}
1957
1958#[cfg(test)]
1959#[allow(clippy::unwrap_used, clippy::expect_used)]
1960mod tests {
1961 use super::*;
1962 use serde_json::json;
1963
1964 #[derive(Parser)]
1966 struct Wrapper {
1967 #[command(subcommand)]
1968 cmd: WorktreesSubcommands,
1969 }
1970
1971 fn parse(args: &[&str]) -> WorktreesSubcommands {
1972 let mut full = vec!["omni-dev"];
1973 full.extend_from_slice(args);
1974 Wrapper::try_parse_from(full).unwrap().cmd
1975 }
1976
1977 #[test]
1978 fn list_parses_flags_and_defaults() {
1979 assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
1981 let cmd = ListCommand::try_parse_from(["list"]).unwrap();
1983 assert_eq!(cmd.output, TableOrJson::Table);
1984 assert!(!cmd.json);
1985 assert!(cmd.socket.is_none());
1986
1987 let cmd =
1988 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1989 assert_eq!(cmd.output, TableOrJson::Json);
1990 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1991 }
1992
1993 #[test]
1994 fn list_deprecated_json_flag_still_parses() {
1995 let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
1997 assert!(cmd.json);
1998 assert_eq!(cmd.output, TableOrJson::Table);
1999 }
2000
2001 #[test]
2002 fn tree_parses_flags_and_defaults() {
2003 assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
2005 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2006 assert_eq!(cmd.output, TableOrJson::Table);
2007 assert!(cmd.socket.is_none());
2008
2009 let cmd =
2010 TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
2011 assert_eq!(cmd.output, TableOrJson::Json);
2012 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2013 }
2014
2015 #[test]
2016 fn focus_parses_path_and_socket() {
2017 assert!(matches!(
2019 parse(&["focus", "/home/me/wt"]),
2020 WorktreesSubcommands::Focus(_)
2021 ));
2022 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
2024 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2025 assert!(cmd.socket.is_none());
2026
2027 let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
2028 .unwrap();
2029 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2030
2031 assert!(FocusCommand::try_parse_from(["focus"]).is_err());
2033 }
2034
2035 #[tokio::test]
2036 async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
2037 let cmd = FocusCommand {
2040 path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
2041 socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
2042 };
2043 let err = cmd.execute().await.unwrap_err();
2044 assert!(
2045 err.to_string().contains("cannot resolve worktree path"),
2046 "{err}"
2047 );
2048 }
2049
2050 #[tokio::test]
2051 async fn focus_sends_the_open_op_for_an_existing_folder() {
2052 let (_dir, sock, server) =
2056 fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
2057 let target = tempfile::tempdir().unwrap();
2058 let cmd = WorktreesCommand {
2059 command: WorktreesSubcommands::Focus(FocusCommand {
2060 path: target.path().to_path_buf(),
2061 socket: Some(sock),
2062 }),
2063 };
2064 cmd.execute().await.unwrap();
2065 server.await.unwrap();
2066 }
2067
2068 #[test]
2069 fn render_windows_handles_empty_replies() {
2070 assert_eq!(
2071 render_windows(&json!({ "windows": [] })),
2072 "No open windows."
2073 );
2074 assert_eq!(render_windows(&json!({})), "No open windows.");
2075 }
2076
2077 #[test]
2078 fn render_windows_renders_rows() {
2079 let result = json!({ "windows": [{
2080 "key": "w1",
2081 "repo": "omni-dev",
2082 "branch": "issue-1011",
2083 "ahead": 2,
2084 "behind": 1,
2085 "folders": ["/home/me/omni-dev", "/home/me/docs"],
2086 "last_seen": "2000-01-01T00:00:00Z",
2087 }]});
2088 let table = render_windows(&result);
2089 assert!(table.contains("omni-dev"), "{table}");
2090 assert!(table.contains("issue-1011"), "{table}");
2092 assert!(table.contains("+2 -1"), "{table}");
2093 assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
2095 assert_eq!(table.lines().count(), 2, "{table}");
2097 }
2098
2099 #[test]
2100 fn render_windows_prefers_main_repo_over_companion_repo() {
2101 let result = json!({ "windows": [{
2105 "key": "w1",
2106 "repo": "issue-1250",
2107 "main_repo": "omni-dev",
2108 "branch": "issue-1250",
2109 "folders": ["/home/me/worktrees/issue-1250"],
2110 "last_seen": "2000-01-01T00:00:00Z",
2111 }]});
2112 let table = render_windows(&result);
2113 assert!(table.contains("omni-dev"), "{table}");
2114 let data_row = table.lines().nth(1).unwrap();
2117 assert!(data_row.starts_with("omni-dev"), "{data_row}");
2118 }
2119
2120 #[test]
2121 fn repo_name_falls_back_to_companion_repo_then_dash() {
2122 assert_eq!(
2123 repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
2124 "omni-dev"
2125 );
2126 assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
2127 assert_eq!(repo_name(&json!({})), "-");
2128 }
2129
2130 #[test]
2131 fn render_windows_strips_control_bytes() {
2132 let result = json!({ "windows": [{
2135 "key": "w1",
2136 "repo": "evil\x1b[31mrepo",
2137 "branch": "br\ranch\x07\u{9b}2J",
2138 "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
2139 "last_seen": "2000-01-01T00:00:00Z",
2140 }]});
2141 let table = render_windows(&result);
2142 assert!(
2143 !table.contains(|c: char| c.is_control() && c != '\n'),
2144 "{table:?}"
2145 );
2146 assert!(table.contains("evil[31mrepo"), "{table:?}");
2148 assert!(table.contains("branch2J"), "{table:?}");
2149 assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
2150 assert_eq!(table.lines().count(), 2, "{table:?}");
2152 }
2153
2154 #[test]
2155 fn sync_summary_formats_or_dashes() {
2156 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2157 assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
2158 assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
2160 assert_eq!(sync_summary(&json!({})), "-");
2161 }
2162
2163 #[test]
2164 fn sync_summary_appends_main_behind_when_present() {
2165 assert_eq!(
2168 sync_summary(&json!({ "ahead": 2, "behind": 1, "main_behind": 5 })),
2169 "+2 -1 main-5"
2170 );
2171 assert_eq!(sync_summary(&json!({ "main_behind": 7 })), "- main-7");
2172 assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2175 }
2176
2177 #[test]
2178 fn folder_summary_strips_control_bytes() {
2179 assert_eq!(
2180 folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
2181 "/a[2J/b"
2182 );
2183 }
2184
2185 #[test]
2186 fn folder_summary_counts_extra_folders() {
2187 assert_eq!(folder_summary(&json!({ "folders": [] })), "");
2188 assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
2189 assert_eq!(
2190 folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
2191 "/a (+2)"
2192 );
2193 }
2194
2195 #[test]
2196 fn age_secs_handles_absent_and_unparseable_and_past() {
2197 assert_eq!(age_secs(None), 0);
2198 assert_eq!(age_secs(Some("not-a-timestamp")), 0);
2199 assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
2200 }
2201
2202 #[test]
2203 fn render_tree_handles_empty_replies() {
2204 assert_eq!(
2205 render_tree(&json!({ "repos": [] })),
2206 "No repositories open."
2207 );
2208 assert_eq!(render_tree(&json!({})), "No repositories open.");
2209 }
2210
2211 #[test]
2212 fn worktree_paths_collects_every_worktree_in_render_order() {
2213 let result = json!({ "repos": [
2214 { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
2216 { "worktrees": [ { "path": "/c" } ] },
2217 ]});
2218 assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
2219 assert!(worktree_paths(&json!({})).is_empty());
2221 assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
2222 }
2223
2224 #[test]
2225 fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
2226 let mut result = json!({ "repos": [{ "worktrees": [
2230 { "path": "/a", "branch": "main" },
2231 { "path": "/b", "branch": "feature" },
2232 ]}]});
2233 let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
2234 merge_ahead_behind(&mut result, results.as_object().unwrap());
2235
2236 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2237 let a = &worktrees[0];
2238 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
2239 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2240 assert_eq!(sync_summary(a), "+2 -1");
2242 let b = &worktrees[1];
2243 assert!(b.get("ahead").is_none(), "{b:?}");
2244 assert!(b.get("behind").is_none(), "{b:?}");
2245 assert_eq!(sync_summary(b), "-");
2246 }
2247
2248 #[test]
2249 fn merge_ahead_behind_folds_main_behind_independently_of_ahead_behind() {
2250 let mut result = json!({ "repos": [{ "worktrees": [
2254 { "path": "/a", "branch": "feature" },
2255 { "path": "/b", "branch": "no-upstream" },
2256 { "path": "/c", "branch": "main" },
2257 ]}]});
2258 let results = json!({
2259 "/a": { "ahead": 1, "behind": 1, "main_behind": 3 },
2260 "/b": { "main_behind": 7 },
2261 "/c": { "ahead": 1, "behind": 1 },
2262 });
2263 merge_ahead_behind(&mut result, results.as_object().unwrap());
2264
2265 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2266 let a = &worktrees[0];
2267 assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(1));
2268 assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2269 assert_eq!(a.get("main_behind").and_then(Value::as_u64), Some(3));
2270
2271 let b = &worktrees[1];
2272 assert!(b.get("ahead").is_none(), "{b:?}");
2273 assert!(b.get("behind").is_none(), "{b:?}");
2274 assert_eq!(b.get("main_behind").and_then(Value::as_u64), Some(7));
2275
2276 let c = &worktrees[2];
2277 assert_eq!(c.get("ahead").and_then(Value::as_u64), Some(1));
2278 assert_eq!(c.get("behind").and_then(Value::as_u64), Some(1));
2279 assert!(c.get("main_behind").is_none(), "{c:?}");
2280 }
2281
2282 #[test]
2283 fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
2284 let mut result = json!({ "repos": [{ "worktrees": [
2288 "not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
2292 let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
2294
2295 let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2296 assert_eq!(worktrees[0], json!("not-an-object"));
2298 assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
2300 assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
2302 assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
2303 }
2304
2305 #[tokio::test]
2306 async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
2307 let mut result = json!({ "repos": [] });
2310 let before = result.clone();
2311 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2312 assert_eq!(result, before);
2313 }
2314
2315 #[tokio::test]
2316 async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
2317 let mut result =
2320 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2321 enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2322 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2323 assert!(wt.get("ahead").is_none(), "{wt:?}");
2324 assert!(wt.get("behind").is_none(), "{wt:?}");
2325 }
2326
2327 fn fake_daemon_reply(
2332 reply: Value,
2333 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2334 use futures::{SinkExt, StreamExt};
2335 use tokio::net::UnixListener;
2336 use tokio_util::codec::{Framed, LinesCodec};
2337
2338 let dir = tempfile::tempdir_in("/tmp").unwrap();
2340 let sock = dir.path().join("d.sock");
2341 let listener = UnixListener::bind(&sock).unwrap();
2342 let server = tokio::spawn(async move {
2343 let (stream, _) = listener.accept().await.unwrap();
2344 let mut framed = Framed::new(stream, LinesCodec::new());
2345 let _req = framed.next().await.unwrap().unwrap();
2346 framed
2347 .send(serde_json::to_string(&reply).unwrap())
2348 .await
2349 .unwrap();
2350 });
2351 (dir, sock, server)
2352 }
2353
2354 fn fake_daemon_replies(
2358 replies: Vec<Value>,
2359 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2360 use futures::{SinkExt, StreamExt};
2361 use tokio::net::UnixListener;
2362 use tokio_util::codec::{Framed, LinesCodec};
2363
2364 let dir = tempfile::tempdir_in("/tmp").unwrap();
2365 let sock = dir.path().join("d.sock");
2366 let listener = UnixListener::bind(&sock).unwrap();
2367 let server = tokio::spawn(async move {
2368 for reply in replies {
2369 let (stream, _) = listener.accept().await.unwrap();
2370 let mut framed = Framed::new(stream, LinesCodec::new());
2371 let _req = framed.next().await.unwrap().unwrap();
2372 framed
2373 .send(serde_json::to_string(&reply).unwrap())
2374 .await
2375 .unwrap();
2376 }
2377 });
2378 (dir, sock, server)
2379 }
2380
2381 #[tokio::test]
2382 async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
2383 let (_dir, sock, server) = fake_daemon_reply(
2384 json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
2385 );
2386 let mut result =
2387 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2388 enrich_ahead_behind(&sock, &mut result).await;
2389 server.await.unwrap();
2390
2391 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2392 assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
2393 assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
2394 }
2395
2396 #[tokio::test]
2397 async fn enrich_ahead_behind_ignores_a_reply_without_results() {
2398 let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
2401 let mut result =
2402 json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2403 enrich_ahead_behind(&sock, &mut result).await;
2404 server.await.unwrap();
2405
2406 let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2407 assert!(wt.get("ahead").is_none(), "{wt:?}");
2408 assert!(wt.get("behind").is_none(), "{wt:?}");
2409 }
2410
2411 #[test]
2412 fn render_tree_groups_repos_and_worktrees() {
2413 let result = json!({ "repos": [{
2414 "main_repo": "omni-dev",
2415 "github": { "owner": "rust-works", "name": "omni-dev" },
2416 "root": "/home/me/omni-dev",
2417 "worktrees": [
2418 { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
2419 "is_main": true, "open": true, "window_key": "w1" },
2420 { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
2421 "is_main": false, "open": false },
2422 ],
2423 }]});
2424 let out = render_tree(&result);
2425 let header = out.lines().next().unwrap();
2427 assert!(header.contains("omni-dev"), "{out}");
2428 assert!(header.contains("github: rust-works/omni-dev"), "{out}");
2429 assert!(header.contains("/home/me/omni-dev"), "{out}");
2430 assert!(
2432 out.lines()
2433 .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
2434 "{out}"
2435 );
2436 let linked = out
2438 .lines()
2439 .find(|l| l.contains("issue-1300"))
2440 .unwrap_or_default();
2441 assert!(!linked.contains('*'), "{linked}");
2442 assert!(!linked.contains("open"), "{linked}");
2443 assert!(linked.contains("+1 -3"), "{linked}");
2444 assert_eq!(out.lines().count(), 3, "{out}");
2446 }
2447
2448 #[test]
2449 fn render_tree_separates_multiple_repos_with_blank_line() {
2450 let result = json!({ "repos": [
2451 {
2452 "main_repo": "alpha",
2453 "root": "/r/alpha",
2454 "worktrees": [
2455 { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
2456 ],
2457 },
2458 {
2459 "main_repo": "beta",
2460 "root": "/r/beta",
2461 "worktrees": [
2462 { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
2463 ],
2464 },
2465 ]});
2466 let out = render_tree(&result);
2467 assert!(
2469 out.contains("\n\nbeta"),
2470 "repos not blank-separated: {out:?}"
2471 );
2472 let alpha = out.find("alpha").unwrap();
2473 let beta = out.find("beta").unwrap();
2474 assert!(alpha < beta, "repo order not preserved: {out}");
2475 assert_eq!(out.lines().count(), 5, "{out:?}");
2476 }
2477
2478 #[test]
2479 fn render_tree_omits_github_for_non_github_repo() {
2480 let result = json!({ "repos": [{
2481 "main_repo": "internal",
2482 "root": "/srv/internal",
2483 "worktrees": [
2484 { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
2485 ],
2486 }]});
2487 let out = render_tree(&result);
2488 assert!(!out.contains("github:"), "{out}");
2489 assert!(out.lines().next().unwrap().contains("internal"), "{out}");
2490 }
2491
2492 #[test]
2493 fn render_tree_strips_control_bytes() {
2494 let result = json!({ "repos": [{
2497 "main_repo": "evil\x1b[31mrepo",
2498 "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
2499 "root": "/tmp/r\x1b]0;x\x07oot",
2500 "worktrees": [
2501 { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
2502 ],
2503 }]});
2504 let out = render_tree(&result);
2505 assert!(
2506 !out.contains(|c: char| c.is_control() && c != '\n'),
2507 "{out:?}"
2508 );
2509 assert_eq!(out.lines().count(), 2, "{out:?}");
2511 }
2512
2513 #[test]
2514 fn github_summary_needs_both_owner_and_name() {
2515 assert_eq!(
2516 github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
2517 Some("github: o/n")
2518 );
2519 assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
2520 assert_eq!(github_summary(&json!({})), None);
2521 }
2522
2523 #[test]
2524 fn reply_payload_unwraps_ok_and_maps_errors() {
2525 assert_eq!(
2527 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2528 json!({ "a": 1 })
2529 );
2530 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2532 assert!(err.to_string().contains("boom"), "{err}");
2533 let err = reply_payload(DaemonReply {
2535 ok: false,
2536 payload: Value::Null,
2537 error: None,
2538 })
2539 .unwrap_err();
2540 assert!(err.to_string().contains("unknown error"), "{err}");
2541 }
2542
2543 #[test]
2546 fn new_subcommands_route_and_require_their_args() {
2547 assert!(matches!(
2548 parse(&["close", "/home/me/wt"]),
2549 WorktreesSubcommands::Close(_)
2550 ));
2551 assert!(matches!(
2552 parse(&["show-closed"]),
2553 WorktreesSubcommands::ShowClosed(_)
2554 ));
2555 assert!(matches!(
2556 parse(&["register", "--key", "w1"]),
2557 WorktreesSubcommands::Register(_)
2558 ));
2559 assert!(matches!(
2560 parse(&["heartbeat", "--key", "w1"]),
2561 WorktreesSubcommands::Heartbeat(_)
2562 ));
2563 assert!(matches!(
2564 parse(&["unregister", "--key", "w1"]),
2565 WorktreesSubcommands::Unregister(_)
2566 ));
2567
2568 assert!(CloseCommand::try_parse_from(["close"]).is_err());
2570 assert!(RegisterCommand::try_parse_from(["register"]).is_err());
2571 assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
2572 assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
2573 }
2574
2575 #[test]
2576 fn close_parses_flags() {
2577 let cmd = CloseCommand::try_parse_from([
2578 "close",
2579 "/home/me/wt",
2580 "--window-only",
2581 "--dry-run",
2582 "-y",
2583 "--socket",
2584 "/tmp/d.sock",
2585 ])
2586 .unwrap();
2587 assert_eq!(cmd.path, Path::new("/home/me/wt"));
2588 assert!(cmd.window_only && cmd.dry_run && cmd.yes);
2589 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2590
2591 let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
2593 assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
2594 }
2595
2596 #[test]
2597 fn tree_follow_flag_parses() {
2598 let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
2599 assert!(cmd.follow);
2600 let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
2601 assert!(cmd.follow);
2602 assert_eq!(cmd.output, TableOrJson::Json);
2603 let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2604 assert!(!cmd.follow);
2605 }
2606
2607 #[test]
2608 fn show_closed_parses_optional_bool() {
2609 assert!(ShowClosedCommand::try_parse_from(["show-closed"])
2610 .unwrap()
2611 .value
2612 .is_none());
2613 assert_eq!(
2614 ShowClosedCommand::try_parse_from(["show-closed", "false"])
2615 .unwrap()
2616 .value,
2617 Some(false)
2618 );
2619 assert_eq!(
2620 ShowClosedCommand::try_parse_from(["show-closed", "true"])
2621 .unwrap()
2622 .value,
2623 Some(true)
2624 );
2625 assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
2627 }
2628
2629 #[test]
2630 fn register_collects_repeated_folders() {
2631 let cmd = RegisterCommand::try_parse_from([
2632 "register",
2633 "--key",
2634 "w1",
2635 "--folder",
2636 "/a",
2637 "--folder",
2638 "/b",
2639 "--repo-name",
2640 "r",
2641 "--pid",
2642 "42",
2643 ])
2644 .unwrap();
2645 assert_eq!(cmd.key, "w1");
2646 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
2647 assert_eq!(cmd.repo_name.as_deref(), Some("r"));
2648 assert_eq!(cmd.pid, Some(42));
2649 }
2650
2651 #[test]
2652 fn answer_is_yes_accepts_only_affirmatives() {
2653 for yes in ["y", "Y", "yes", "YES", " yes \n"] {
2654 assert!(answer_is_yes(yes), "{yes:?}");
2655 }
2656 for no in ["", "n", "no", "nope", "true", "\n"] {
2657 assert!(!answer_is_yes(no), "{no:?}");
2658 }
2659 }
2660
2661 #[test]
2662 fn confirm_prompt_mentions_risks_only_when_present() {
2663 assert!(confirm_prompt(true).contains("risks"));
2667 assert!(!confirm_prompt(false).contains("risks"));
2668 assert!(confirm_prompt(true).contains("[y/N]"));
2669 assert!(confirm_prompt(false).contains("[y/N]"));
2670 }
2671
2672 #[test]
2673 fn read_line_from_maps_input_and_eof() {
2674 use std::io::Cursor;
2675 assert_eq!(
2679 read_line_from(&mut Cursor::new("y\n")).as_deref(),
2680 Some("y\n")
2681 );
2682 assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
2683 assert_eq!(
2684 read_line_from(&mut Cursor::new("no-newline")).as_deref(),
2685 Some("no-newline")
2686 );
2687 }
2688
2689 #[test]
2690 fn render_safety_report_renders_fields_and_notes() {
2691 let report = json!({
2692 "removable": true,
2693 "is_main": false,
2694 "open": true,
2695 "window_key": "w1",
2696 "window_folder_count": 2,
2697 "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
2698 "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
2699 });
2700 let out = render_safety_report(Path::new("/home/me/wt"), &report);
2701 assert!(out.contains("/home/me/wt"), "{out}");
2702 assert!(out.contains("removable: true"), "{out}");
2703 assert!(
2704 out.contains("open in a window: yes (key w1, 2 folder(s))"),
2705 "{out}"
2706 );
2707 assert!(out.contains("[dirty] uncommitted changes"), "{out}");
2708 assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
2709 }
2710
2711 #[test]
2712 fn render_safety_report_handles_no_window_and_no_notes() {
2713 let report = json!({ "removable": false, "is_main": true, "open": false });
2714 let out = render_safety_report(Path::new("/r"), &report);
2715 assert!(out.contains("removable: false"), "{out}");
2716 assert!(out.contains("main working tree: true"), "{out}");
2717 assert!(out.contains("open in a window: no"), "{out}");
2718 assert!(!out.contains("risks:"), "{out}");
2720 assert!(!out.contains("info:"), "{out}");
2721 }
2722
2723 #[test]
2724 fn render_safety_report_strips_control_bytes() {
2725 let report = json!({
2728 "removable": true, "is_main": false, "open": true,
2729 "window_key": "w\x1b[31m1", "window_folder_count": 1,
2730 "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
2731 "info": [],
2732 });
2733 let out = render_safety_report(Path::new("/r"), &report);
2734 assert!(
2735 !out.contains(|c: char| c.is_control() && c != '\n'),
2736 "{out:?}"
2737 );
2738 }
2739
2740 fn fake_daemon_seq(
2746 replies: Vec<Value>,
2747 ) -> (
2748 tempfile::TempDir,
2749 PathBuf,
2750 tokio::task::JoinHandle<Vec<Value>>,
2751 ) {
2752 use futures::{SinkExt, StreamExt};
2753 use tokio::net::UnixListener;
2754 use tokio_util::codec::{Framed, LinesCodec};
2755
2756 let dir = tempfile::tempdir_in("/tmp").unwrap();
2757 let sock = dir.path().join("d.sock");
2758 let listener = UnixListener::bind(&sock).unwrap();
2759 let server = tokio::spawn(async move {
2760 let mut requests = Vec::new();
2761 for reply in replies {
2762 let (stream, _) = listener.accept().await.unwrap();
2763 let mut framed = Framed::new(stream, LinesCodec::new());
2764 let req = framed.next().await.unwrap().unwrap();
2765 requests.push(serde_json::from_str::<Value>(&req).unwrap());
2766 framed
2767 .send(serde_json::to_string(&reply).unwrap())
2768 .await
2769 .unwrap();
2770 }
2771 requests
2772 });
2773 (dir, sock, server)
2774 }
2775
2776 #[tokio::test]
2777 async fn close_window_only_sends_remove_false() {
2778 let (_dir, sock, server) =
2779 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
2780 let target = tempfile::tempdir().unwrap();
2781 CloseCommand {
2782 path: target.path().to_path_buf(),
2783 window_only: true,
2784 dry_run: false,
2785 yes: false,
2786 socket: Some(sock),
2787 }
2788 .execute()
2789 .await
2790 .unwrap();
2791 let reqs = server.await.unwrap();
2792 assert_eq!(reqs.len(), 1);
2795 assert_eq!(reqs[0]["op"], "close");
2796 assert_eq!(reqs[0]["payload"]["remove"], json!(false));
2797 assert!(
2798 reqs[0]["payload"].get("confirmed").is_none(),
2799 "{:?}",
2800 reqs[0]
2801 );
2802 let want = std::fs::canonicalize(target.path()).unwrap();
2804 assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
2805 }
2806
2807 #[tokio::test]
2808 async fn close_window_only_dry_run_never_contacts_the_daemon() {
2809 let target = tempfile::tempdir().unwrap();
2812 CloseCommand {
2813 path: target.path().to_path_buf(),
2814 window_only: true,
2815 dry_run: true,
2816 yes: false,
2817 socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
2818 }
2819 .execute()
2820 .await
2821 .unwrap();
2822 }
2823
2824 #[tokio::test]
2825 async fn close_dry_run_only_runs_phase_one() {
2826 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2828 "ok": true,
2829 "payload": { "removable": true, "is_main": false, "open": false,
2830 "window_folder_count": 0, "risks": [], "info": [] }
2831 })]);
2832 let target = tempfile::tempdir().unwrap();
2833 CloseCommand {
2834 path: target.path().to_path_buf(),
2835 window_only: false,
2836 dry_run: true,
2837 yes: false,
2838 socket: Some(sock),
2839 }
2840 .execute()
2841 .await
2842 .unwrap();
2843 let reqs = server.await.unwrap();
2844 assert_eq!(reqs.len(), 1);
2846 assert_eq!(reqs[0]["op"], "close");
2847 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2848 assert!(
2849 reqs[0]["payload"].get("confirmed").is_none(),
2850 "{:?}",
2851 reqs[0]
2852 );
2853 }
2854
2855 #[tokio::test]
2856 async fn close_yes_executes_phase_two() {
2857 let (_dir, sock, server) = fake_daemon_seq(vec![
2859 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2860 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2861 json!({ "ok": true, "payload": { "removed": true } }),
2862 ]);
2863 let target = tempfile::tempdir().unwrap();
2864 CloseCommand {
2865 path: target.path().to_path_buf(),
2866 window_only: false,
2867 dry_run: false,
2868 yes: true,
2869 socket: Some(sock),
2870 }
2871 .execute()
2872 .await
2873 .unwrap();
2874 let reqs = server.await.unwrap();
2875 assert_eq!(reqs.len(), 2);
2877 assert_eq!(reqs[0]["op"], "close");
2878 assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2879 assert!(
2880 reqs[0]["payload"].get("confirmed").is_none(),
2881 "{:?}",
2882 reqs[0]
2883 );
2884 assert_eq!(reqs[1]["op"], "close");
2885 assert_eq!(reqs[1]["payload"]["remove"], json!(true));
2886 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2887 assert!(
2889 reqs[1]["payload"].get("requester_key").is_none(),
2890 "{:?}",
2891 reqs[1]
2892 );
2893 }
2894
2895 #[tokio::test]
2896 async fn close_refuses_a_non_removable_target() {
2897 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2900 "ok": true,
2901 "payload": { "removable": false, "is_main": true, "open": false,
2902 "window_folder_count": 0, "risks": [], "info": [] }
2903 })]);
2904 let target = tempfile::tempdir().unwrap();
2905 let err = CloseCommand {
2906 path: target.path().to_path_buf(),
2907 window_only: false,
2908 dry_run: false,
2909 yes: true,
2910 socket: Some(sock),
2911 }
2912 .execute()
2913 .await
2914 .unwrap_err();
2915 assert!(
2916 err.to_string().contains("not a removable worktree"),
2917 "{err}"
2918 );
2919 assert_eq!(server.await.unwrap().len(), 1);
2921 }
2922
2923 #[tokio::test]
2924 async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
2925 let err = CloseCommand {
2926 path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
2927 window_only: false,
2928 dry_run: false,
2929 yes: true,
2930 socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
2931 }
2932 .execute()
2933 .await
2934 .unwrap_err();
2935 assert!(
2936 err.to_string().contains("cannot resolve worktree path"),
2937 "{err}"
2938 );
2939 }
2940
2941 #[tokio::test]
2942 async fn show_closed_sets_and_reads() {
2943 let (_dir, sock, server) =
2945 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2946 ShowClosedCommand {
2947 value: Some(false),
2948 socket: Some(sock),
2949 }
2950 .execute()
2951 .await
2952 .unwrap();
2953 let reqs = server.await.unwrap();
2954 assert_eq!(reqs[0]["op"], "set-show-closed");
2955 assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
2956
2957 let (_dir, sock, server) = fake_daemon_seq(vec![
2959 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2960 ]);
2961 ShowClosedCommand {
2962 value: None,
2963 socket: Some(sock),
2964 }
2965 .execute()
2966 .await
2967 .unwrap();
2968 assert_eq!(server.await.unwrap()[0]["op"], "tree");
2970 }
2971
2972 #[tokio::test]
2973 async fn register_heartbeat_unregister_send_their_ops() {
2974 let (_dir, sock, server) =
2975 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2976 RegisterCommand {
2977 key: "w1".to_string(),
2978 folders: vec![PathBuf::from("/a")],
2979 repo_name: Some("r".to_string()),
2980 title: None,
2981 pid: Some(7),
2982 socket: Some(sock),
2983 }
2984 .execute()
2985 .await
2986 .unwrap();
2987 let reqs = server.await.unwrap();
2988 assert_eq!(reqs[0]["op"], "register");
2990 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2991 assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
2992 assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
2993 assert_eq!(reqs[0]["payload"]["pid"], json!(7));
2994
2995 let (_dir, sock, server) = fake_daemon_seq(vec![
2996 json!({ "ok": true, "payload": { "known": true, "close": true } }),
2997 ]);
2998 HeartbeatCommand {
2999 key: "w1".to_string(),
3000 socket: Some(sock),
3001 }
3002 .execute()
3003 .await
3004 .unwrap();
3005 let reqs = server.await.unwrap();
3006 assert_eq!(reqs[0]["op"], "heartbeat");
3007 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
3008
3009 let (_dir, sock, server) =
3010 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
3011 UnregisterCommand {
3012 key: "w1".to_string(),
3013 socket: Some(sock),
3014 }
3015 .execute()
3016 .await
3017 .unwrap();
3018 let reqs = server.await.unwrap();
3019 assert_eq!(reqs[0]["op"], "unregister");
3020 assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
3021 }
3022
3023 #[tokio::test]
3024 async fn tree_follow_renders_each_pushed_frame() {
3025 use crate::daemon::testutil::fake_daemon_stream;
3026
3027 let (_dir, sock, server) = fake_daemon_stream(vec![
3029 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3030 json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
3031 ]);
3032 follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
3033 server.await.unwrap();
3034
3035 let (_dir, sock, server) = fake_daemon_stream(vec![
3038 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3039 ]);
3040 follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
3041 server.await.unwrap();
3042
3043 let (_dir, sock, server) = fake_daemon_stream(vec![
3046 json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3047 ]);
3048 TreeCommand {
3049 socket: Some(sock),
3050 output: TableOrJson::Json,
3051 follow: true,
3052 }
3053 .execute()
3054 .await
3055 .unwrap();
3056 server.await.unwrap();
3057 }
3058
3059 #[tokio::test]
3060 async fn worktrees_command_routes_each_new_subcommand() {
3061 let target = tempfile::tempdir().unwrap();
3065 WorktreesCommand {
3067 command: WorktreesSubcommands::Close(CloseCommand {
3068 path: target.path().to_path_buf(),
3069 window_only: true,
3070 dry_run: true,
3071 yes: false,
3072 socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
3073 }),
3074 }
3075 .execute()
3076 .await
3077 .unwrap();
3078
3079 WorktreesCommand {
3082 command: WorktreesSubcommands::Rebase(RebaseCommand {
3083 paths: vec![target.path().to_path_buf()],
3084 dry_run: true,
3085 ..rebase_cmd()
3086 }),
3087 }
3088 .execute()
3089 .await
3090 .unwrap();
3091
3092 let (_d, sock, server) =
3094 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3095 WorktreesCommand {
3096 command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
3097 value: Some(true),
3098 socket: Some(sock),
3099 }),
3100 }
3101 .execute()
3102 .await
3103 .unwrap();
3104 server.await.unwrap();
3105
3106 let (_d, sock, server) = fake_daemon_seq(vec![json!({
3108 "ok": true,
3109 "payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
3110 })]);
3111 WorktreesCommand {
3112 command: WorktreesSubcommands::Reposition(RepositionCommand {
3113 paths: Vec::new(),
3114 reference: None,
3115 dry_run: false,
3116 undo: true,
3117 output: TableOrJson::Table,
3118 socket: Some(sock),
3119 }),
3120 }
3121 .execute()
3122 .await
3123 .unwrap();
3124 server.await.unwrap();
3125
3126 let (_d, sock, server) = fake_daemon_seq(vec![
3129 json!({ "ok": true, "payload": { "windows": [] } }),
3130 json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
3131 ]);
3132 WorktreesCommand {
3133 command: WorktreesSubcommands::Reload(ReloadCommand {
3134 paths: Vec::new(),
3135 output: TableOrJson::Table,
3136 socket: Some(sock),
3137 }),
3138 }
3139 .execute()
3140 .await
3141 .unwrap();
3142 server.await.unwrap();
3143
3144 let (_d, sock, server) =
3146 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3147 WorktreesCommand {
3148 command: WorktreesSubcommands::Register(RegisterCommand {
3149 key: "w1".to_string(),
3150 folders: vec![],
3151 repo_name: None,
3152 title: None,
3153 pid: None,
3154 socket: Some(sock),
3155 }),
3156 }
3157 .execute()
3158 .await
3159 .unwrap();
3160 server.await.unwrap();
3161
3162 let (_d, sock, server) =
3164 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
3165 WorktreesCommand {
3166 command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
3167 key: "w1".to_string(),
3168 socket: Some(sock),
3169 }),
3170 }
3171 .execute()
3172 .await
3173 .unwrap();
3174 server.await.unwrap();
3175
3176 let (_d, sock, server) =
3178 fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
3179 WorktreesCommand {
3180 command: WorktreesSubcommands::Unregister(UnregisterCommand {
3181 key: "w1".to_string(),
3182 socket: Some(sock),
3183 }),
3184 }
3185 .execute()
3186 .await
3187 .unwrap();
3188 server.await.unwrap();
3189 }
3190
3191 #[tokio::test]
3192 async fn close_aborts_when_confirmation_is_declined() {
3193 let (_dir, sock, server) = fake_daemon_seq(vec![json!({
3197 "ok": true,
3198 "payload": { "removable": true, "is_main": false, "open": false,
3199 "window_folder_count": 0, "risks": [], "info": [] }
3200 })]);
3201 let target = tempfile::tempdir().unwrap();
3202 CloseCommand {
3203 path: target.path().to_path_buf(),
3204 window_only: false,
3205 dry_run: false,
3206 yes: false,
3207 socket: Some(sock),
3208 }
3209 .execute_with(|_has_risks| async { false })
3210 .await
3211 .unwrap();
3212 assert_eq!(server.await.unwrap().len(), 1);
3213 }
3214
3215 #[tokio::test]
3216 async fn close_deletes_when_confirmation_is_accepted() {
3217 let (_dir, sock, server) = fake_daemon_seq(vec![
3220 json!({ "ok": true, "payload": { "removable": true, "is_main": false,
3221 "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
3222 json!({ "ok": true, "payload": { "removed": true } }),
3223 ]);
3224 let target = tempfile::tempdir().unwrap();
3225 CloseCommand {
3226 path: target.path().to_path_buf(),
3227 window_only: false,
3228 dry_run: false,
3229 yes: false,
3230 socket: Some(sock),
3231 }
3232 .execute_with(|_has_risks| async { true })
3233 .await
3234 .unwrap();
3235 let reqs = server.await.unwrap();
3236 assert_eq!(reqs.len(), 2);
3237 assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
3238 }
3239
3240 #[tokio::test]
3241 async fn confirm_removal_with_decides_from_the_answer() {
3242 assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
3245 assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
3246 assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
3247 assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
3248 assert!(!confirm_removal_with(false, async { None }).await);
3249 }
3250
3251 fn rebase_cmd() -> RebaseCommand {
3255 RebaseCommand {
3256 paths: Vec::new(),
3257 all: false,
3258 onto: None,
3259 autostash: false,
3260 dry_run: false,
3261 keep_conflicts: false,
3262 yes: false,
3263 output: TableOrJson::Table,
3264 repo: crate::cli::repo_arg::RepoArg::default(),
3265 }
3266 }
3267
3268 #[test]
3269 fn rebase_parses_paths_and_flags() {
3270 let cmd = RebaseCommand::try_parse_from([
3271 "rebase",
3272 "/wt/a",
3273 "/wt/b",
3274 "--onto",
3275 "origin/release",
3276 "--autostash",
3277 "--dry-run",
3278 "--keep-conflicts",
3279 "-y",
3280 "-o",
3281 "json",
3282 ])
3283 .unwrap();
3284 assert_eq!(
3285 cmd.paths,
3286 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3287 );
3288 assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
3289 assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
3290 assert!(matches!(cmd.output, TableOrJson::Json));
3291 }
3292
3293 #[test]
3294 fn rebase_defaults_are_conservative() {
3295 let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
3296 assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
3297 assert!(!cmd.keep_conflicts);
3300 assert_eq!(cmd.onto, None);
3301 assert!(matches!(cmd.output, TableOrJson::Table));
3302 }
3303
3304 #[test]
3305 fn rebase_requires_a_target() {
3306 let err = rebase_cmd().selection(None).unwrap_err().to_string();
3309 assert!(err.contains("--all"), "expected a usage hint, got: {err}");
3310 }
3311
3312 #[test]
3313 fn rebase_rejects_paths_together_with_all() {
3314 let cmd = RebaseCommand {
3315 paths: vec![PathBuf::from("/wt/a")],
3316 all: true,
3317 ..rebase_cmd()
3318 };
3319 let err = cmd.selection(None).unwrap_err().to_string();
3320 assert!(err.contains("not both"), "got: {err}");
3321 }
3322
3323 #[test]
3324 fn rebase_selection_maps_paths_and_all() {
3325 let cmd = RebaseCommand {
3326 paths: vec![PathBuf::from("/wt/a")],
3327 ..rebase_cmd()
3328 };
3329 assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
3330 let all = RebaseCommand {
3331 all: true,
3332 ..rebase_cmd()
3333 };
3334 assert!(matches!(
3335 all.selection(None).unwrap(),
3336 Selection::All { .. }
3337 ));
3338 }
3339
3340 #[test]
3341 fn rebase_prompt_agrees_in_number() {
3342 assert!(rebase_prompt(1).contains("1 worktree ("));
3343 assert!(rebase_prompt(3).contains("3 worktrees ("));
3344 assert!(rebase_prompt(2).contains("rewrites branch history"));
3346 }
3347
3348 #[tokio::test]
3349 async fn confirm_rebase_with_decides_from_the_answer() {
3350 assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
3351 assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
3352 assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
3353 assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
3354 assert!(!confirm_rebase_with(1, async { None }).await);
3355 }
3356
3357 #[test]
3358 fn fetch_line_reports_each_repos_single_fetch() {
3359 let ok = FetchOutcome {
3360 repo_root: PathBuf::from("/repo"),
3361 onto: "origin/main".to_string(),
3362 fetched: true,
3363 ok: true,
3364 detail: None,
3365 };
3366 assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
3367
3368 let failed = FetchOutcome {
3369 detail: Some("host unreachable".to_string()),
3370 ok: false,
3371 ..ok.clone()
3372 };
3373 assert!(fetch_line(&failed).contains("FAILED"));
3374
3375 let local = FetchOutcome {
3376 fetched: false,
3377 onto: "develop".to_string(),
3378 ..ok
3379 };
3380 assert!(fetch_line(&local).contains("nothing fetched"));
3381 }
3382
3383 #[test]
3384 fn outcome_rows_render_each_status() {
3385 let row = |result| {
3386 outcome_row(&WorktreeOutcome {
3387 path: PathBuf::from("/wt"),
3388 branch: Some("feature".to_string()),
3389 onto: "origin/main".to_string(),
3390 result,
3391 })
3392 };
3393 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
3394 assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
3395 assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
3396 assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
3397 assert!(row(RebaseResult::Skipped {
3398 reason: SkipReason::Dirty
3399 })
3400 .contains("--autostash"));
3401 assert!(row(RebaseResult::Conflict {
3402 detail: "CONFLICT (content)".to_string(),
3403 left_in_place: false,
3404 })
3405 .contains("conflict"));
3406 let kept = row(RebaseResult::Conflict {
3409 detail: "CONFLICT (content)".to_string(),
3410 left_in_place: true,
3411 });
3412 assert!(kept.contains("conflict"), "{kept}");
3413 assert!(kept.contains("git rebase --continue"), "{kept}");
3414 assert!(row(RebaseResult::FetchFailed {
3415 detail: "host unreachable".to_string()
3416 })
3417 .contains("fetch-failed"));
3418 assert!(row(RebaseResult::Skipped {
3420 reason: SkipReason::DetachedHead
3421 })
3422 .contains("detached HEAD"));
3423 assert!(row(RebaseResult::Skipped {
3424 reason: SkipReason::OperationInProgress
3425 })
3426 .contains("in progress"));
3427 assert!(row(RebaseResult::Skipped {
3428 reason: SkipReason::NotAWorktree
3429 })
3430 .contains("not a git worktree"));
3431 assert!(row(RebaseResult::Skipped {
3432 reason: SkipReason::NoOntoRef
3433 })
3434 .contains("resolve the target ref"));
3435 }
3436
3437 #[test]
3438 fn print_emits_both_json_and_table_without_error() {
3439 let fetches = vec![FetchOutcome {
3440 repo_root: PathBuf::from("/r"),
3441 onto: "origin/main".to_string(),
3442 fetched: true,
3443 ok: true,
3444 detail: None,
3445 }];
3446 let outcomes = vec![WorktreeOutcome {
3447 path: PathBuf::from("/wt"),
3448 branch: Some("feature".to_string()),
3449 onto: "origin/main".to_string(),
3450 result: RebaseResult::UpToDate,
3451 }];
3452 let json_cmd = RebaseCommand {
3454 dry_run: true,
3455 output: TableOrJson::Json,
3456 ..rebase_cmd()
3457 };
3458 json_cmd.print(true, &fetches, &outcomes).unwrap();
3459 rebase_cmd().print(false, &fetches, &outcomes).unwrap();
3460 }
3461
3462 #[test]
3463 fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
3464 assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
3465 let long = "x".repeat(200);
3466 let out = brief(&long);
3467 assert_eq!(out.chars().count(), 100);
3468 assert!(out.ends_with("..."));
3469 assert_eq!(brief("a\u{7}b"), "ab");
3471 }
3472
3473 #[test]
3474 fn empty_report_renders_placeholders() {
3475 assert_eq!(render_fetches(&[]), "No repository selected.");
3476 assert_eq!(render_outcomes(&[]), "No worktrees selected.");
3477 }
3478
3479 #[allow(clippy::await_holding_lock)]
3485 #[tokio::test]
3486 async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
3487 let _guard = crate::git::worktree_batch::test_serial_lock();
3492 let Some(scenario) = BehindScenario::build() else {
3493 return; };
3495 let before = scenario.worktree_head();
3496 RebaseCommand {
3497 paths: vec![scenario.worktree.clone()],
3498 ..rebase_cmd()
3499 }
3500 .execute_with(None, |pending| async move {
3501 assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
3502 false
3503 })
3504 .await
3505 .unwrap();
3506 assert_eq!(
3507 scenario.worktree_head(),
3508 before,
3509 "declining the confirm must not rebase"
3510 );
3511 }
3512
3513 #[allow(clippy::await_holding_lock)]
3516 #[tokio::test]
3517 async fn rebase_confirmed_rebases_the_behind_worktree() {
3518 let _guard = crate::git::worktree_batch::test_serial_lock();
3521 let Some(scenario) = BehindScenario::build() else {
3522 return; };
3524 let before = scenario.worktree_head();
3525 RebaseCommand {
3526 paths: vec![scenario.worktree.clone()],
3527 ..rebase_cmd()
3528 }
3529 .execute_with(None, |pending| async move {
3530 assert_eq!(pending, 1);
3531 true
3532 })
3533 .await
3534 .unwrap();
3535 assert_ne!(
3536 scenario.worktree_head(),
3537 before,
3538 "confirming the prompt must rebase the worktree"
3539 );
3540 }
3541
3542 struct BehindScenario {
3546 _root: tempfile::TempDir,
3547 worktree: PathBuf,
3548 }
3549
3550 impl BehindScenario {
3551 fn build() -> Option<Self> {
3552 use git2::Repository;
3553 let root = tempfile::tempdir().ok()?;
3554 let origin = root.path().join("origin.git");
3555 let local = root.path().join("local");
3556 let worktree = root.path().join("feature");
3557 std::fs::create_dir_all(&origin).ok()?;
3558 std::fs::create_dir_all(&local).ok()?;
3559 run(&origin, &["init", "--bare", "-b", "main"])?;
3560 run(&local, &["init", "-b", "main"])?;
3561 Self::identity(&local)?;
3562 std::fs::write(local.join("f.txt"), "one\n").ok()?;
3563 run(&local, &["add", "f.txt"])?;
3564 run(&local, &["commit", "-m", "one"])?;
3565 run(&local, &["remote", "add", "origin", origin.to_str()?])?;
3566 run(&local, &["push", "-u", "origin", "main"])?;
3567 run(
3568 &local,
3569 &[
3570 "worktree",
3571 "add",
3572 "-b",
3573 "feature",
3574 worktree.to_str()?,
3575 "main",
3576 ],
3577 )?;
3578 let repo = Repository::open_bare(&origin).ok()?;
3581 let parent = repo
3582 .find_commit(repo.refname_to_id("refs/heads/main").ok()?)
3583 .ok()?;
3584 let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
3585 let blob = repo.blob(b"two\n").ok()?;
3586 builder.insert("f.txt", blob, 0o100_644).ok()?;
3587 let tree = repo.find_tree(builder.write().ok()?).ok()?;
3588 let sig = git2::Signature::now("Other", "other@example.com").ok()?;
3589 repo.commit(
3590 Some("refs/heads/main"),
3591 &sig,
3592 &sig,
3593 "two",
3594 &tree,
3595 &[&parent],
3596 )
3597 .ok()?;
3598 Some(Self {
3599 _root: root,
3600 worktree,
3601 })
3602 }
3603
3604 fn identity(dir: &Path) -> Option<()> {
3607 run(dir, &["config", "user.name", "Test"])?;
3608 run(dir, &["config", "user.email", "test@example.com"])?;
3609 run(dir, &["config", "commit.gpgsign", "false"])
3610 }
3611
3612 fn worktree_head(&self) -> String {
3613 let out = std::process::Command::new("git")
3614 .current_dir(&self.worktree)
3615 .args(["rev-parse", "HEAD"])
3616 .output();
3617 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3618 .unwrap_or_default()
3619 }
3620 }
3621
3622 fn run(dir: &Path, args: &[&str]) -> Option<()> {
3624 let output = std::process::Command::new("git")
3625 .current_dir(dir)
3626 .args(args)
3627 .output()
3628 .ok()?;
3629 output.status.success().then_some(())
3630 }
3631
3632 #[test]
3635 fn merge_queue_parses_paths_and_flags() {
3636 assert!(matches!(
3638 parse(&["merge-queue", "/a"]),
3639 WorktreesSubcommands::MergeQueue(_)
3640 ));
3641 let cmd =
3643 MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
3644 assert_eq!(cmd.paths.len(), 2);
3645 assert!(cmd.check);
3646 assert!(!cmd.yes);
3647 assert!(cmd.socket.is_none());
3648 let cmd = MergeQueueCommand::try_parse_from([
3650 "merge-queue",
3651 "/a",
3652 "-y",
3653 "--socket",
3654 "/tmp/d.sock",
3655 ])
3656 .unwrap();
3657 assert!(cmd.yes);
3658 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
3659 assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
3661 }
3662
3663 #[test]
3664 fn render_eligibility_report_lists_eligible_and_skipped() {
3665 let report = json!({
3666 "eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
3667 "skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
3668 });
3669 let out = render_eligibility_report(&report);
3670 assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
3671 assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
3672 assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
3673 }
3674
3675 #[test]
3676 fn render_enqueue_result_marks_already_queued_and_failures() {
3677 let result = json!({
3678 "queued": [
3679 { "number": 10, "path": "/a" },
3680 { "number": 11, "path": "/b", "already_queued": true },
3681 ],
3682 "failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
3683 "skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
3684 });
3685 let out = render_enqueue_result(&result);
3686 assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
3687 assert!(out.contains("queued: PR #10"), "{out}");
3688 assert!(out.contains("PR #11 (already queued)"), "{out}");
3689 assert!(
3690 out.contains("failed: PR #12 — merge queue not enabled"),
3691 "{out}"
3692 );
3693 }
3694
3695 #[test]
3696 fn render_eligibility_report_strips_control_bytes() {
3697 let report = json!({
3700 "eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
3701 "skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
3702 });
3703 let out = render_eligibility_report(&report);
3704 assert!(
3705 !out.contains(|c: char| c.is_control() && c != '\n'),
3706 "{out:?}"
3707 );
3708 }
3709
3710 #[tokio::test]
3711 async fn confirm_enqueue_with_decides_from_the_answer() {
3712 assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
3713 assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
3714 assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
3715 assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
3716 assert!(!confirm_enqueue_with(3, async { None }).await);
3717 }
3718
3719 #[tokio::test]
3720 async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
3721 let cmd = MergeQueueCommand {
3724 paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
3725 check: true,
3726 yes: false,
3727 socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
3728 };
3729 let err = cmd.execute().await.unwrap_err();
3730 assert!(
3731 err.to_string().contains("cannot resolve worktree path"),
3732 "{err}"
3733 );
3734 }
3735
3736 #[tokio::test]
3737 async fn merge_queue_check_prints_the_report_and_never_confirms() {
3738 let target = tempfile::tempdir().unwrap();
3739 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3740 "ok": true,
3741 "payload": {
3742 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3743 "skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
3744 }
3745 })]);
3746 let cmd = MergeQueueCommand {
3747 paths: vec![target.path().to_path_buf()],
3748 check: true,
3749 yes: false,
3750 socket: Some(sock),
3751 };
3752 cmd.execute_with(|_| async { panic!("must not confirm on --check") })
3754 .await
3755 .unwrap();
3756 server.await.unwrap();
3757 }
3758
3759 #[tokio::test]
3760 async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
3761 let target = tempfile::tempdir().unwrap();
3762 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3763 "ok": true,
3764 "payload": {
3765 "eligible": [],
3766 "skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
3767 }
3768 })]);
3769 let cmd = MergeQueueCommand {
3770 paths: vec![target.path().to_path_buf()],
3771 check: false,
3772 yes: false,
3773 socket: Some(sock),
3774 };
3775 cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
3777 .await
3778 .unwrap();
3779 server.await.unwrap();
3780 }
3781
3782 #[tokio::test]
3783 async fn merge_queue_aborts_when_confirmation_is_declined() {
3784 let target = tempfile::tempdir().unwrap();
3785 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3786 "ok": true,
3787 "payload": {
3788 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3789 "skipped": [],
3790 }
3791 })]);
3792 let cmd = MergeQueueCommand {
3793 paths: vec![target.path().to_path_buf()],
3794 check: false,
3795 yes: false,
3796 socket: Some(sock),
3797 };
3798 cmd.execute_with(|count| async move {
3800 assert_eq!(count, 1);
3801 false
3802 })
3803 .await
3804 .unwrap();
3805 server.await.unwrap();
3806 }
3807
3808 #[tokio::test]
3809 async fn merge_queue_enqueues_after_confirmation() {
3810 let target = tempfile::tempdir().unwrap();
3811 let (_dir, sock, server) = fake_daemon_replies(vec![
3812 json!({
3813 "ok": true,
3814 "payload": {
3815 "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3816 "skipped": [],
3817 }
3818 }),
3819 json!({
3820 "ok": true,
3821 "payload": {
3822 "queued": [{ "path": "/a", "number": 9 }],
3823 "skipped": [],
3824 "failed": [],
3825 }
3826 }),
3827 ]);
3828 let cmd = MergeQueueCommand {
3829 paths: vec![target.path().to_path_buf()],
3830 check: false,
3831 yes: false,
3832 socket: Some(sock),
3833 };
3834 cmd.execute_with(|_| async { true }).await.unwrap();
3836 server.await.unwrap();
3837 }
3838
3839 #[tokio::test]
3840 async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
3841 let target = tempfile::tempdir().unwrap();
3845 let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3846 "ok": true,
3847 "payload": { "eligible": [], "skipped": [] }
3848 })]);
3849 let cmd = WorktreesCommand {
3850 command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
3851 paths: vec![target.path().to_path_buf()],
3852 check: true,
3853 yes: false,
3854 socket: Some(sock),
3855 }),
3856 };
3857 cmd.execute().await.unwrap();
3858 server.await.unwrap();
3859 }
3860
3861 #[test]
3864 fn reposition_parses_flags_and_enforces_the_undo_split() {
3865 let WorktreesSubcommands::Reposition(cmd) = parse(&[
3866 "reposition",
3867 "--reference",
3868 "/wt/ref",
3869 "/wt/a",
3870 "/wt/b",
3871 "--dry-run",
3872 "-o",
3873 "json",
3874 ]) else {
3875 panic!("expected the Reposition variant");
3876 };
3877 assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
3878 assert_eq!(
3879 cmd.paths,
3880 vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3881 );
3882 assert!(cmd.dry_run);
3883 assert!(!cmd.undo);
3884 assert_eq!(cmd.output, TableOrJson::Json);
3885
3886 let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
3888 panic!("expected the Reposition variant");
3889 };
3890 assert!(undo.undo);
3891 assert!(undo.reference.is_none());
3892 }
3893
3894 #[test]
3895 fn reposition_rejects_a_missing_reference_and_undo_combinations() {
3896 assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
3899 assert!(RepositionCommand::try_parse_from([
3902 "reposition",
3903 "--undo",
3904 "--reference",
3905 "/wt/ref",
3906 ])
3907 .is_err());
3908 assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
3909 }
3910
3911 #[test]
3912 fn window_key_for_matches_a_canonicalized_folder() {
3913 let dir = tempfile::tempdir_in("/tmp").unwrap();
3914 let wt = dir.path().join("tree");
3915 std::fs::create_dir(&wt).unwrap();
3916 let canonical = std::fs::canonicalize(&wt).unwrap();
3917 let windows = json!({
3918 "windows": [
3919 { "key": "other", "folders": ["/definitely/not/here"] },
3920 { "key": "wanted", "folders": [canonical.to_string_lossy()] },
3921 ]
3922 });
3923 assert_eq!(
3924 window_key_for(&windows, &wt, "repositioned").unwrap(),
3925 "wanted"
3926 );
3927 }
3928
3929 #[test]
3930 fn window_key_for_errors_when_no_window_has_it_open() {
3931 let dir = tempfile::tempdir_in("/tmp").unwrap();
3935 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
3936 .expect_err("an unopened worktree must not resolve");
3937 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3938 let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
3941 .expect_err("an unopened worktree must not resolve");
3942 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3943 let missing = dir.path().join("gone");
3945 let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
3946 .expect_err("a nonexistent path must not resolve");
3947 assert!(err.to_string().contains("cannot resolve"), "{err:#}");
3948 }
3949
3950 #[test]
3951 fn reload_command_requires_at_least_one_path() {
3952 assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
3955 let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
3956 assert_eq!(cmd.paths.len(), 2);
3957 assert!(matches!(cmd.output, TableOrJson::Table));
3958 assert!(cmd.socket.is_none());
3959 }
3960
3961 #[test]
3962 fn render_reload_reports_what_was_signalled_not_reloaded() {
3963 let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
3966 assert_eq!(out, "Signalled 2 of 2 windows to reload.");
3967 assert!(!out.contains("Reloaded"), "{out}");
3968 let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
3970 assert_eq!(one, "Signalled 1 of 1 window to reload.");
3971 }
3972
3973 #[test]
3974 fn render_reload_names_windows_that_had_already_closed() {
3975 let out = render_reload(&json!({
3978 "requested": 3,
3979 "signalled": 1,
3980 "unknown": ["w2", "w3"],
3981 }));
3982 assert!(
3983 out.starts_with("Signalled 1 of 3 windows to reload."),
3984 "{out}"
3985 );
3986 assert!(out.contains("No longer open"), "{out}");
3987 assert!(out.contains("w2, w3"), "{out}");
3988 }
3989
3990 #[test]
3991 fn render_reload_tolerates_a_reply_missing_every_field() {
3992 assert_eq!(
3995 render_reload(&json!({})),
3996 "Signalled 0 of 0 windows to reload."
3997 );
3998 }
3999
4000 #[test]
4001 fn render_reposition_explains_a_missing_permission() {
4002 let out = render_reposition(&json!({ "trusted": false, "results": [] }));
4003 assert!(out.contains("Accessibility permission"), "{out}");
4004 assert!(out.contains("daemon restart"), "{out}");
4005 }
4006
4007 #[test]
4008 fn render_reposition_reports_a_blocked_batch() {
4009 let out = render_reposition(&json!({
4010 "trusted": true,
4011 "blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
4012 "results": [],
4013 }));
4014 assert!(out.contains("Nothing was moved"), "{out}");
4015 assert!(out.contains("reference-ambiguous"), "{out}");
4016 assert!(out.contains("2 windows match"), "{out}");
4017 }
4018
4019 #[test]
4020 fn render_reposition_renders_the_reference_and_per_target_outcomes() {
4021 let out = render_reposition(&json!({
4022 "trusted": true,
4023 "reference": {
4024 "key": "r",
4025 "title": "ref-tree",
4026 "frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
4027 },
4028 "moved": 1,
4029 "skipped": 1,
4030 "results": [
4031 { "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
4032 { "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
4033 ],
4034 }));
4035 assert!(
4036 out.contains("Reference: ref-tree 800×600 at (10, 21)"),
4037 "{out}"
4038 );
4039 assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
4040 assert!(out.contains("moved: a-tree"), "{out}");
4041 assert!(out.contains("ambiguous: b-tree"), "{out}");
4042 }
4043
4044 #[test]
4045 fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
4046 let out = render_reposition(&json!({
4049 "trusted": true,
4050 "results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
4051 }));
4052 assert!(out.contains("no-window: keyless"), "{out}");
4053 let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
4055 assert!(empty.contains("(nothing to report)"), "{empty}");
4056 }
4057
4058 #[test]
4059 fn render_reposition_strips_control_bytes_from_daemon_strings() {
4060 let out = render_reposition(&json!({
4063 "trusted": true,
4064 "results": [{
4065 "key": "k",
4066 "title": "evil\u{1b}[31mred",
4067 "outcome": "moved",
4068 "detail": "ok\u{7}",
4069 }],
4070 }));
4071 assert!(!out.contains('\u{1b}'), "{out:?}");
4072 assert!(!out.contains('\u{7}'), "{out:?}");
4073 }
4074
4075 #[test]
4076 fn render_frame_formats_or_dashes() {
4077 assert_eq!(render_frame(None), "-");
4078 assert_eq!(
4079 render_frame(Some(
4080 &json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
4081 )),
4082 "100×50 at (2, -2)"
4083 );
4084 assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
4086 }
4087
4088 #[test]
4089 fn print_reposition_emits_both_formats() {
4090 let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
4091 print_reposition(TableOrJson::Table, &reply).unwrap();
4092 print_reposition(TableOrJson::Json, &reply).unwrap();
4093 }
4094
4095 #[tokio::test]
4096 async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
4097 let dir = tempfile::tempdir_in("/tmp").unwrap();
4098 let reference = dir.path().join("ref");
4099 let target = dir.path().join("tgt");
4100 std::fs::create_dir(&reference).unwrap();
4101 std::fs::create_dir(&target).unwrap();
4102 let (canon_ref, canon_tgt) = (
4103 std::fs::canonicalize(&reference).unwrap(),
4104 std::fs::canonicalize(&target).unwrap(),
4105 );
4106
4107 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4110 json!({ "ok": true, "payload": { "windows": [
4111 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4112 { "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
4113 ] } }),
4114 json!({ "ok": true, "payload": {
4115 "trusted": true,
4116 "moved": 1,
4117 "skipped": 0,
4118 "results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
4119 } }),
4120 ]);
4121
4122 RepositionCommand {
4123 paths: vec![target],
4124 reference: Some(reference),
4125 dry_run: false,
4126 undo: false,
4127 output: TableOrJson::Json,
4128 socket: Some(sock),
4129 }
4130 .execute()
4131 .await
4132 .unwrap();
4133 server.await.unwrap();
4134 }
4135
4136 #[tokio::test]
4137 async fn reposition_undo_skips_the_list_lookup_entirely() {
4138 let (_dir, sock, server) = fake_daemon_reply(json!({
4141 "ok": true,
4142 "payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
4143 }));
4144 RepositionCommand {
4145 paths: Vec::new(),
4146 reference: None,
4147 dry_run: false,
4148 undo: true,
4149 output: TableOrJson::Table,
4150 socket: Some(sock),
4151 }
4152 .execute()
4153 .await
4154 .unwrap();
4155 server.await.unwrap();
4156 }
4157
4158 #[tokio::test]
4159 async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
4160 let dir = tempfile::tempdir_in("/tmp").unwrap();
4161 let reference = dir.path().join("ref");
4162 let target = dir.path().join("tgt");
4163 std::fs::create_dir(&reference).unwrap();
4164 std::fs::create_dir(&target).unwrap();
4165 let canon_ref = std::fs::canonicalize(&reference).unwrap();
4166
4167 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4170 "ok": true,
4171 "payload": { "windows": [
4172 { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4173 ] },
4174 }));
4175 let err = RepositionCommand {
4176 paths: vec![target],
4177 reference: Some(reference),
4178 dry_run: true,
4179 undo: false,
4180 output: TableOrJson::Table,
4181 socket: Some(sock),
4182 }
4183 .execute()
4184 .await
4185 .expect_err("an unopened target must abort the command");
4186 assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
4187 server.await.unwrap();
4188 }
4189
4190 #[tokio::test]
4191 async fn reposition_surfaces_a_daemon_error() {
4192 let (_dir, sock, server) = fake_daemon_reply(json!({
4193 "ok": false,
4194 "error": "unknown worktrees op: reposition",
4195 }));
4196 let err = RepositionCommand {
4197 paths: Vec::new(),
4198 reference: None,
4199 dry_run: false,
4200 undo: true,
4201 output: TableOrJson::Table,
4202 socket: Some(sock),
4203 }
4204 .execute()
4205 .await
4206 .expect_err("an `ok:false` reply must not be reported as success");
4207 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4208 server.await.unwrap();
4209 }
4210
4211 fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
4214 let dir = tempfile::tempdir_in("/tmp").unwrap();
4215 let a = dir.path().join("a");
4216 let b = dir.path().join("b");
4217 std::fs::create_dir(&a).unwrap();
4218 std::fs::create_dir(&b).unwrap();
4219 let list = json!({ "ok": true, "payload": { "windows": [
4220 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4221 { "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
4222 ] } });
4223 (dir, a, b, list)
4224 }
4225
4226 #[tokio::test]
4227 async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
4228 let (_dir, a, b, list) = reload_fixture();
4229 let (_sock_dir, sock, server) = fake_daemon_seq(vec![
4232 list,
4233 json!({ "ok": true, "payload": {
4234 "requested": 2, "signalled": 2, "unknown": [],
4235 } }),
4236 ]);
4237
4238 ReloadCommand {
4239 paths: vec![a, b],
4240 output: TableOrJson::Table,
4241 socket: Some(sock),
4242 }
4243 .execute()
4244 .await
4245 .unwrap();
4246
4247 let requests = server.await.unwrap();
4250 assert_eq!(requests[1]["op"], "reload");
4251 assert_eq!(
4252 requests[1]["payload"]["target_keys"],
4253 json!(["key-a", "key-b"])
4254 );
4255 assert!(
4256 requests[1]["payload"].get("requester_key").is_none(),
4257 "a CLI process is not a window, so it must not claim to be one"
4258 );
4259 }
4260
4261 #[tokio::test]
4262 async fn reload_json_output_passes_the_reply_through_verbatim() {
4263 let (_dir, a, _b, list) = reload_fixture();
4264 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4265 list,
4266 json!({ "ok": true, "payload": {
4267 "requested": 1, "signalled": 0, "unknown": ["key-a"],
4268 } }),
4269 ]);
4270 ReloadCommand {
4273 paths: vec![a],
4274 output: TableOrJson::Json,
4275 socket: Some(sock),
4276 }
4277 .execute()
4278 .await
4279 .unwrap();
4280 server.await.unwrap();
4281 }
4282
4283 #[tokio::test]
4284 async fn reload_fails_before_the_op_when_a_target_has_no_window() {
4285 let (_dir, a, b, _list) = reload_fixture();
4286 let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4290 "ok": true,
4291 "payload": { "windows": [
4292 { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4293 ] },
4294 }));
4295 let err = ReloadCommand {
4296 paths: vec![a, b],
4297 output: TableOrJson::Table,
4298 socket: Some(sock),
4299 }
4300 .execute()
4301 .await
4302 .expect_err("an unopened target must abort the command");
4303 assert!(err.to_string().contains("can be reloaded"), "{err:#}");
4304 server.await.unwrap();
4305 }
4306
4307 #[tokio::test]
4308 async fn reload_surfaces_a_daemon_error() {
4309 let (_dir, a, _b, list) = reload_fixture();
4310 let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4311 list,
4312 json!({ "ok": false, "error": "unknown worktrees op: reload" }),
4313 ]);
4314 let err = ReloadCommand {
4317 paths: vec![a],
4318 output: TableOrJson::Table,
4319 socket: Some(sock),
4320 }
4321 .execute()
4322 .await
4323 .expect_err("an `ok:false` reply must not be reported as success");
4324 assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4325 server.await.unwrap();
4326 }
4327
4328 fn push_cmd() -> PushCommand {
4332 PushCommand {
4333 paths: Vec::new(),
4334 all: false,
4335 dry_run: false,
4336 yes: false,
4337 output: TableOrJson::Table,
4338 repo: crate::cli::repo_arg::RepoArg::default(),
4339 }
4340 }
4341
4342 #[test]
4343 fn push_parses_paths_and_flags() {
4344 let cmd = PushCommand::try_parse_from(["push", "/a", "/b", "--dry-run", "-y"]).unwrap();
4345 assert_eq!(cmd.paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
4346 assert!(cmd.dry_run && cmd.yes);
4347 assert!(!cmd.all);
4348 }
4349
4350 #[test]
4351 fn push_exposes_no_force_escape_hatch() {
4352 for flag in ["--force", "-f", "--no-force-if-includes"] {
4355 assert!(
4356 PushCommand::try_parse_from(["push", "/a", flag]).is_err(),
4357 "{flag} must not be accepted"
4358 );
4359 }
4360 }
4361
4362 #[test]
4363 fn push_selection_requires_paths_or_all() {
4364 let err = push_cmd().selection(None).unwrap_err().to_string();
4365 assert!(err.contains("--all"), "{err}");
4366
4367 let both = PushCommand {
4368 paths: vec![PathBuf::from("/a")],
4369 all: true,
4370 ..push_cmd()
4371 };
4372 let err = both.selection(None).unwrap_err().to_string();
4373 assert!(err.contains("not both"), "{err}");
4374 }
4375
4376 #[test]
4377 fn push_selection_resolves_relative_paths_against_the_repo_flag() {
4378 let cmd = PushCommand {
4379 paths: vec![PathBuf::from("wt-a"), PathBuf::from("/abs/wt-b")],
4380 ..push_cmd()
4381 };
4382 let Selection::Paths(paths) = cmd.selection(Some(Path::new("/base"))).unwrap() else {
4383 panic!("expected an explicit path selection");
4384 };
4385 assert_eq!(
4386 paths,
4387 vec![PathBuf::from("/base/wt-a"), PathBuf::from("/abs/wt-b")],
4388 "a relative path resolves against -C, an absolute one is left alone"
4389 );
4390 }
4391
4392 #[tokio::test]
4393 async fn push_dry_run_reaches_no_remote_and_pushes_nothing() {
4394 let dir = tempfile::tempdir().unwrap();
4397 PushCommand {
4398 paths: vec![dir.path().to_path_buf()],
4399 dry_run: true,
4400 ..push_cmd()
4401 }
4402 .execute_with(None, |_, _| async { panic!("a dry run must not confirm") })
4403 .await
4404 .unwrap();
4405 }
4406
4407 #[tokio::test]
4408 async fn push_declining_the_confirmation_publishes_nothing() {
4409 let (_root, origin, wt) = push_scenario();
4410 let before = origin_tip(&origin, "refs/heads/feature");
4411
4412 PushCommand {
4413 paths: vec![wt],
4414 ..push_cmd()
4415 }
4416 .execute_with(None, |pending, forced| async move {
4417 assert_eq!((pending, forced), (1, 1));
4418 false
4419 })
4420 .await
4421 .unwrap();
4422
4423 assert_eq!(
4424 origin_tip(&origin, "refs/heads/feature"),
4425 before,
4426 "declining must leave the remote exactly as it was"
4427 );
4428 }
4429
4430 #[tokio::test]
4431 async fn push_confirming_force_pushes_with_the_lease() {
4432 let (_root, origin, wt) = push_scenario();
4433 let rewritten = git2::Repository::open(&wt)
4434 .unwrap()
4435 .head()
4436 .unwrap()
4437 .target();
4438
4439 PushCommand {
4440 paths: vec![wt],
4441 ..push_cmd()
4442 }
4443 .execute_with(None, |_, _| async { true })
4444 .await
4445 .unwrap();
4446
4447 assert_eq!(
4448 origin_tip(&origin, "refs/heads/feature"),
4449 rewritten,
4450 "confirming publishes the rewritten tip"
4451 );
4452 }
4453
4454 #[test]
4455 fn push_prompt_calls_out_the_force_count_separately() {
4456 assert_eq!(push_prompt(1, 0), "Push 1 branch? [y/N] ");
4457 assert_eq!(push_prompt(3, 0), "Push 3 branches? [y/N] ");
4458
4459 let forced = push_prompt(3, 2);
4460 assert!(forced.contains("force-pushing 2 with a lease"), "{forced}");
4461 assert!(
4462 forced.contains("rewritten history"),
4463 "the prompt must say what is actually being published: {forced}"
4464 );
4465 }
4466
4467 #[tokio::test]
4468 async fn push_confirmation_treats_anything_but_yes_as_no() {
4469 assert!(confirm_push_with(1, 1, async { Some("y\n".into()) }).await);
4470 assert!(confirm_push_with(1, 1, async { Some("YES".into()) }).await);
4471 assert!(!confirm_push_with(1, 1, async { Some("n".into()) }).await);
4472 assert!(
4473 !confirm_push_with(1, 1, async { None }).await,
4474 "EOF must never be read as consent"
4475 );
4476 }
4477
4478 #[test]
4479 fn push_rows_render_each_status_with_its_own_instruction() {
4480 let outcome = |result| worktree_push::WorktreeOutcome {
4481 path: PathBuf::from("/wt"),
4482 branch: Some("feature".into()),
4483 remote: "origin".into(),
4484 remote_branch: "feature".into(),
4485 result,
4486 };
4487 let rendered = render_push_outcomes(&[
4488 outcome(worktree_push::PushResult::WouldForce {
4489 ahead: 2,
4490 behind: 1,
4491 }),
4492 outcome(worktree_push::PushResult::Rejected {
4493 detail: "stale info".into(),
4494 stale: true,
4495 }),
4496 outcome(worktree_push::PushResult::Skipped {
4497 reason: worktree_push::SkipReason::DefaultBranchForcePush,
4498 }),
4499 ]);
4500 assert!(rendered.contains("would-force"), "{rendered}");
4501 assert!(rendered.contains("origin/feature"), "{rendered}");
4502 assert!(
4503 rendered.contains("`git fetch` and rebase"),
4504 "a lease refusal must name the fix, not just quote git: {rendered}"
4505 );
4506 assert!(
4507 rendered.contains("refusing to force-push the remote default branch"),
4508 "{rendered}"
4509 );
4510 }
4511
4512 #[test]
4513 fn push_renders_an_empty_selection_without_a_bare_header() {
4514 assert_eq!(render_push_outcomes(&[]), "No worktrees selected.");
4515 }
4516
4517 #[test]
4518 fn push_rows_render_every_remaining_status_and_skip_reason() {
4519 use worktree_push::{PushResult, SkipReason};
4523 let outcome = |result| worktree_push::WorktreeOutcome {
4524 path: PathBuf::from("/wt"),
4525 branch: Some("feature".into()),
4526 remote: "origin".into(),
4527 remote_branch: "feature".into(),
4528 result,
4529 };
4530 let rendered = render_push_outcomes(&[
4531 outcome(PushResult::UpToDate),
4532 outcome(PushResult::WouldFastForward { ahead: 3 }),
4533 outcome(PushResult::WouldCreate),
4534 outcome(PushResult::Pushed { forced: true }),
4535 outcome(PushResult::Pushed { forced: false }),
4536 outcome(PushResult::Created),
4537 outcome(PushResult::Rejected {
4538 detail: "pre-receive hook declined".into(),
4539 stale: false,
4540 }),
4541 outcome(PushResult::Skipped {
4542 reason: SkipReason::DetachedHead,
4543 }),
4544 outcome(PushResult::Skipped {
4545 reason: SkipReason::NotAWorktree,
4546 }),
4547 outcome(PushResult::Skipped {
4548 reason: SkipReason::NoRemote,
4549 }),
4550 ]);
4551
4552 for expected in [
4553 "up-to-date",
4554 "3 ahead; fast-forward",
4555 "no upstream yet",
4556 "forced with lease",
4557 "fast-forward",
4558 "upstream set",
4559 "pre-receive hook declined",
4560 "detached HEAD",
4561 "not a git worktree",
4562 "no remote to publish to",
4563 ] {
4564 assert!(
4565 rendered.contains(expected),
4566 "missing {expected:?}: {rendered}"
4567 );
4568 }
4569 assert!(
4570 !rendered.contains("`git fetch` and rebase"),
4571 "only a *lease* refusal earns the fetch-and-rebase instruction: {rendered}"
4572 );
4573 }
4574
4575 #[test]
4576 fn push_rows_render_an_unresolved_destination_as_a_dash() {
4577 let rendered = render_push_outcomes(&[worktree_push::WorktreeOutcome {
4580 path: PathBuf::from("/wt"),
4581 branch: None,
4582 remote: String::new(),
4583 remote_branch: String::new(),
4584 result: worktree_push::PushResult::Skipped {
4585 reason: worktree_push::SkipReason::NotAWorktree,
4586 },
4587 }]);
4588 let row = rendered.lines().nth(1).unwrap();
4589 assert!(
4590 row.contains(" - "),
4591 "branch and remote both render as `-`: {row}"
4592 );
4593 }
4594
4595 #[test]
4596 fn push_all_selects_the_repository_rather_than_named_paths() {
4597 let cmd = PushCommand {
4598 all: true,
4599 ..push_cmd()
4600 };
4601 let selection = cmd.selection(Some(Path::new("/base"))).unwrap();
4602 match selection {
4603 Selection::All { base } => assert_eq!(base, PathBuf::from("/base")),
4604 other @ Selection::Paths(_) => panic!("expected an --all selection, got {other:?}"),
4605 }
4606 }
4607
4608 #[tokio::test]
4609 async fn push_json_output_carries_the_dry_run_flag_and_the_outcomes() {
4610 let (_root, _origin, wt) = push_scenario();
4613 let cmd = PushCommand {
4614 paths: vec![wt.clone()],
4615 dry_run: true,
4616 output: TableOrJson::Json,
4617 ..push_cmd()
4618 };
4619 cmd.execute_with(None, |_, _| async { false })
4622 .await
4623 .expect("a dry run must succeed");
4624 }
4625
4626 fn push_scenario() -> (tempfile::TempDir, PathBuf, PathBuf) {
4630 let _guard = crate::git::worktree_batch::test_serial_lock();
4633 let root = tempfile::tempdir().unwrap();
4634 let origin = root.path().join("origin.git");
4635 let local = root.path().join("local");
4636 let wt = root.path().join("feature-wt");
4637 std::fs::create_dir_all(&origin).unwrap();
4638 std::fs::create_dir_all(&local).unwrap();
4639
4640 let git = |dir: &Path, args: &[&str]| {
4641 let out = crate::git::worktree_batch::run_git_in(
4642 &crate::git::resolve_git_binary(),
4643 dir,
4644 args,
4645 )
4646 .unwrap();
4647 assert!(
4648 out.status.success(),
4649 "git {args:?} failed: {}",
4650 String::from_utf8_lossy(&out.stderr)
4651 );
4652 };
4653
4654 git(&origin, &["init", "--bare", "-b", "main"]);
4655 git(&local, &["init", "-b", "main"]);
4656 git(&local, &["config", "user.name", "Test"]);
4657 git(&local, &["config", "user.email", "test@example.com"]);
4658 git(&local, &["config", "commit.gpgsign", "false"]);
4659 std::fs::write(local.join("f.txt"), "base\n").unwrap();
4660 git(&local, &["add", "f.txt"]);
4661 git(&local, &["commit", "-m", "base"]);
4662 git(
4663 &local,
4664 &["remote", "add", "origin", origin.to_str().unwrap()],
4665 );
4666 git(&local, &["push", "-u", "origin", "main"]);
4667 git(
4668 &local,
4669 &[
4670 "worktree",
4671 "add",
4672 "-b",
4673 "feature",
4674 wt.to_str().unwrap(),
4675 "main",
4676 ],
4677 );
4678 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
4679 git(&wt, &["add", "g.txt"]);
4680 git(&wt, &["commit", "-m", "work"]);
4681 git(&wt, &["push", "-u", "origin", "feature"]);
4682 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
4683
4684 (root, origin, std::fs::canonicalize(&wt).unwrap())
4685 }
4686
4687 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
4689 git2::Repository::open_bare(origin)
4690 .unwrap()
4691 .refname_to_id(refname)
4692 .ok()
4693 }
4694}