1mod divergence;
34mod geometry;
35
36use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::process::{Command, Stdio};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex, PoisonError};
41use std::time::{Duration, Instant};
42
43use anyhow::{anyhow, bail, Context, Result};
44use chrono::{DateTime, Utc};
45
46use crate::git::remote::RemoteInfo;
47use crate::git::worktree_batch::Selection;
48use crate::git::worktree_push;
49use crate::git::worktree_rebase;
50use crate::github_rate_limit::{
51 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
52};
53use crate::pr_status::{
54 EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
55};
56use async_trait::async_trait;
57use divergence::{AheadBehindCoordinator, Divergence};
58use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
59use serde::{Deserialize, Serialize};
60use serde_json::{json, Value};
61use tokio::sync::watch;
62use tokio::sync::Mutex as AsyncMutex;
63use tokio::task::JoinHandle;
64use tokio_util::sync::CancellationToken;
65
66use crate::daemon::service::{
67 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
68};
69use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
70
71pub const SERVICE_NAME: &str = "worktrees";
73
74const SUBMENU_TITLE: &str = "Worktrees";
76
77const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
80
81const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
84
85const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
97
98fn menu_refresh_interval() -> Duration {
101 crate::daemon::server::duration_secs_from_env(
102 ENV_MENU_REFRESH_INTERVAL,
103 DEFAULT_MENU_REFRESH_INTERVAL,
104 )
105}
106
107const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
111
112const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
120
121const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
129
130const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
136
137const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
143
144const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
150
151const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
155
156const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
163
164fn pr_poll_interval() -> Duration {
167 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
168}
169
170fn pr_debounce_interval() -> Duration {
173 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
174}
175
176const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
180
181const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
186
187const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
191
192const OPEN_PR_LIST_LIMIT: &str = "100";
195
196fn open_pr_ttl() -> Duration {
199 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
200}
201
202const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
206
207const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
214
215fn rate_limit_poll_interval() -> Duration {
218 crate::daemon::server::duration_secs_from_env(
219 ENV_RATE_LIMIT_POLL_INTERVAL,
220 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
221 )
222}
223
224struct RefreshTask {
226 token: CancellationToken,
228 handle: JoinHandle<()>,
230}
231
232fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
249 grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
250}
251
252fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
267 next.iter().any(|w| !prev.contains(w))
268}
269
270fn next_pr_poll_delay(
288 current: Duration,
289 base: Duration,
290 pending: bool,
291 since_moved: Option<Duration>,
292) -> Duration {
293 if !pending {
294 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
295 }
296 match since_moved {
297 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
299 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
302 }
303}
304
305fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
317 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
318 delay.max(BUDGET_THROTTLE_INTERVAL)
319 } else {
320 delay
321 }
322}
323
324fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
332 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
333 [
337 (prev.and_then(|p| p.graphql), next.graphql),
338 (prev.and_then(|p| p.core), next.core),
339 (prev.and_then(|p| p.search), next.search),
340 ]
341 .into_iter()
342 .any(|(before, after)| over(after) && !over(before))
343}
344
345struct PollerTask {
347 token: CancellationToken,
349 handle: JoinHandle<()>,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
370struct PrWatch {
371 target: PrTarget,
373 upstream_sha: Option<String>,
375}
376
377fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
386 let mut out = Vec::new();
387 for repo in snapshot
388 .get("repos")
389 .and_then(Value::as_array)
390 .into_iter()
391 .flatten()
392 {
393 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
398 continue;
399 }
400 let Some(github) = repo.get("github") else {
401 continue;
402 };
403 let (Some(owner), Some(name)) = (
404 github.get("owner").and_then(Value::as_str),
405 github.get("name").and_then(Value::as_str),
406 ) else {
407 continue;
408 };
409 for wt in repo
410 .get("worktrees")
411 .and_then(Value::as_array)
412 .into_iter()
413 .flatten()
414 {
415 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
416 out.push(PrWatch {
417 upstream_sha: wt
418 .get("upstream_sha")
419 .and_then(Value::as_str)
420 .map(str::to_string),
421 target: PrTarget {
422 owner: owner.to_string(),
423 name: name.to_string(),
424 branch: branch.to_string(),
425 },
426 });
427 }
428 }
429 }
430 out.sort();
431 out.dedup();
432 out
433}
434
435#[cfg(test)]
438fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
439 pr_watch_from_snapshot(snapshot)
440 .into_iter()
441 .map(|w| w.target)
442 .collect()
443}
444
445pub struct WorktreesService {
447 registry: Arc<WorktreesRegistry>,
450 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
456 refresh: Mutex<Option<RefreshTask>>,
458 pr_cache: Arc<PrStatusCache>,
464 poller: Mutex<Option<PollerTask>>,
467 rate_limit_cache: Arc<RateLimitCache>,
475 rate_limit_poller: Mutex<Option<PollerTask>>,
478 tree_cache: Arc<TreeSnapshotCache>,
484 prune_lock: tokio::sync::Mutex<()>,
501 ahead_behind: AheadBehindCoordinator,
505 polling_prefs_path: Mutex<Option<PathBuf>>,
513 pr_cache_path: Mutex<Option<PathBuf>>,
520 pr_warm_start: Mutex<Option<PrWarmStart>>,
525 open_pr_cache: Arc<OpenPrCache>,
530 reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
546 rebase_lock: tokio::sync::Mutex<()>,
558 push_lock: tokio::sync::Mutex<()>,
575}
576
577impl WorktreesService {
578 #[must_use]
583 pub fn new() -> Self {
584 let registry = Arc::new(WorktreesRegistry::new());
585 let pr_cache = Arc::new(PrStatusCache::new());
586 Self {
587 registry: registry.clone(),
588 menu_cache: Arc::new(Mutex::new(None)),
589 refresh: Mutex::new(None),
590 pr_cache: pr_cache.clone(),
591 poller: Mutex::new(None),
592 rate_limit_cache: Arc::new(RateLimitCache::new()),
593 rate_limit_poller: Mutex::new(None),
594 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
595 prune_lock: tokio::sync::Mutex::new(()),
596 ahead_behind: AheadBehindCoordinator::new(),
597 polling_prefs_path: Mutex::new(None),
598 pr_cache_path: Mutex::new(None),
599 pr_warm_start: Mutex::new(None),
600 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
601 reposition_undo: Mutex::new(Vec::new()),
602 rebase_lock: tokio::sync::Mutex::new(()),
603 push_lock: tokio::sync::Mutex::new(()),
604 }
605 }
606
607 pub fn load_polling_prefs(&self, path: PathBuf) {
616 match std::fs::read(&path) {
617 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
618 Ok(prefs) => self
619 .registry
620 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
621 Err(err) => tracing::warn!(
622 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
623 path.display()
624 ),
625 },
626 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
627 Err(err) => tracing::warn!(
628 "could not read worktrees polling prefs at {}: {err:#}",
629 path.display()
630 ),
631 }
632 *self
633 .polling_prefs_path
634 .lock()
635 .unwrap_or_else(PoisonError::into_inner) = Some(path);
636 }
637
638 fn persist_polling_prefs(&self) {
645 let Some(path) = self
646 .polling_prefs_path
647 .lock()
648 .unwrap_or_else(PoisonError::into_inner)
649 .clone()
650 else {
651 return;
652 };
653 let prefs = PollingPrefs {
654 enabled: self
655 .registry
656 .polling_snapshot()
657 .into_iter()
658 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
659 .collect(),
660 };
661 if let Err(err) = write_polling_prefs(&path, &prefs) {
662 tracing::warn!(
663 "could not persist worktrees polling prefs to {}: {err:#}",
664 path.display()
665 );
666 }
667 }
668
669 pub fn load_pr_cache(&self, path: PathBuf) {
682 match std::fs::read(&path) {
683 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
684 Ok(prefs) => {
685 self.pr_cache.seed(
686 prefs
687 .entries
688 .into_iter()
689 .map(|e| (e.target, e.resolution.into_resolution())),
690 );
691 if let Some(polled_at) = prefs.polled_at {
696 let watched = prefs
697 .watched
698 .into_iter()
699 .map(|w| PrWatch {
700 target: w.target,
701 upstream_sha: w.upstream_sha,
702 })
703 .collect();
704 *self
705 .pr_warm_start
706 .lock()
707 .unwrap_or_else(PoisonError::into_inner) =
708 Some(PrWarmStart { watched, polled_at });
709 }
710 }
711 Err(err) => {
715 let at = path.display();
716 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
717 }
718 },
719 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
720 Err(err) => {
721 let at = path.display();
722 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
723 }
724 }
725 *self
726 .pr_cache_path
727 .lock()
728 .unwrap_or_else(PoisonError::into_inner) = Some(path);
729 }
730
731 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
738 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
742 .await
743 }
744
745 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
748 let key = format!("{owner}/{name}");
749 if let Some(prs) = self.open_pr_cache.fresh(&key) {
750 return Ok(prs);
751 }
752 let slug = key.clone();
753 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
754 .await
755 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
756 self.open_pr_cache.store(key, prs.clone());
757 Ok(prs)
758 }
759
760 #[must_use]
764 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
765 self.rate_limit_cache.clone()
766 }
767
768 pub fn start_menu_refresh(&self) {
776 if tokio::runtime::Handle::try_current().is_err() {
777 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
778 return;
779 }
780 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
781 if guard.is_some() {
782 return;
783 }
784 let token = CancellationToken::new();
785 let loop_token = token.clone();
786 let registry = self.registry.clone();
787 let cache = self.menu_cache.clone();
788 let rate_limit_cache = self.rate_limit_cache.clone();
789 let interval = menu_refresh_interval();
792 let handle = tokio::spawn(async move {
793 loop {
794 let entries = registry.list();
798 let rate_limit = rate_limit_cache.get();
801 if let Ok(items) = tokio::task::spawn_blocking(move || {
802 menu_items_for(&entries, rate_limit.as_ref())
803 })
804 .await
805 {
806 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
807 }
808 tokio::select! {
809 () = loop_token.cancelled() => break,
810 () = tokio::time::sleep(interval) => {}
811 }
812 }
813 });
814 *guard = Some(RefreshTask { token, handle });
815 }
816
817 pub fn start_pr_poller(&self) {
848 self.start_pr_poller_with(
851 pr_poll_interval(),
852 pr_debounce_interval(),
853 crate::pr_status::resolve_gh_binary(),
854 );
855 }
856
857 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
868 if tokio::runtime::Handle::try_current().is_err() {
869 tracing::debug!("no tokio runtime; worktrees PR poller not started");
870 return;
871 }
872 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
873 if guard.is_some() {
874 return;
875 }
876 let token = CancellationToken::new();
877 let loop_token = token.clone();
878 let registry = self.registry.clone();
879 let tree_cache = self.tree_cache.clone();
880 let pr_cache = self.pr_cache.clone();
881 let rate_limit_cache = self.rate_limit_cache.clone();
885 let pr_cache_path = self
886 .pr_cache_path
887 .lock()
888 .unwrap_or_else(PoisonError::into_inner)
889 .clone();
890 let warm_start = self
891 .pr_warm_start
892 .lock()
893 .unwrap_or_else(PoisonError::into_inner)
894 .take();
895 let mut changes = self.registry.subscribe_changes();
898 let handle = tokio::spawn(async move {
899 let mut backoff = base;
910 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
917 match warm_start {
918 Some(ws) => {
919 let elapsed = (Utc::now() - ws.polled_at)
920 .to_std()
921 .unwrap_or(Duration::ZERO);
922 (Some(ws.watched), Instant::now().checked_sub(elapsed))
923 }
924 None => (None, None),
925 };
926 let mut moved_at: Option<Instant> = None;
929 'poll: loop {
930 tokio::select! {
933 () = loop_token.cancelled() => break,
934 () = tokio::time::sleep(base) => {}
935 result = changes.changed() => {
938 if result.is_err() {
945 break;
946 }
947 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
955 loop {
956 tokio::select! {
957 () = loop_token.cancelled() => break 'poll,
958 () = tokio::time::sleep(debounce) => break,
959 r = changes.changed() => {
960 if r.is_err() {
961 break 'poll;
962 }
963 if Instant::now() >= overall_deadline {
964 break;
965 }
966 }
967 }
968 }
969 }
970 }
971 let snapshot = tree_cache.snapshot().await;
974 let watch = pr_watch_from_snapshot(&snapshot);
975 if watch.is_empty() {
976 backoff = base;
983 last_poll = None;
984 moved_at = None;
985 continue;
986 }
987 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
991 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
995 pr_cache.retain_targets(&keep);
996 let rate_limit = rate_limit_cache.get();
1002 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
1003 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
1004 let trigger = grew && !over_budget;
1005 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1006 if !grew {
1011 watched = Some(watch);
1012 }
1013 continue;
1014 }
1015 if grew {
1016 backoff = base;
1019 moved_at = Some(Instant::now());
1020 }
1021 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1022 let bin = gh_bin.clone();
1027 let resolved = tokio::task::spawn_blocking(move || {
1028 crate::pr_status::resolve_with_budget(&bin, &targets)
1029 })
1030 .await
1031 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1032 let (pending, resolved_ok) = match resolved {
1039 Ok((resolutions, budget)) => {
1040 if let Some(b) = budget {
1046 tracing::debug!(
1047 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1048 b.cost,
1049 b.used,
1050 b.limit,
1051 b.remaining
1052 );
1053 rate_limit_cache.observe_graphql(RateLimitResource::new(
1054 b.used,
1055 b.limit,
1056 b.remaining,
1057 b.reset,
1058 ));
1059 }
1060 if pr_cache.replace(resolutions) {
1063 registry.bump();
1064 }
1065 (pr_cache.any_pending(), true)
1066 }
1067 Err(err) => {
1068 tracing::debug!("PR badge poll failed: {err:#}");
1069 (false, false)
1070 }
1071 };
1072 last_poll = Some(Instant::now());
1073 watched = Some(watch);
1076 let since_moved = moved_at.map(|at| at.elapsed());
1077 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1078 if resolved_ok {
1084 if let Some(path) = &pr_cache_path {
1085 persist_pr_cache(
1086 path,
1087 &pr_cache,
1088 watched.as_deref().unwrap_or(&[]),
1089 Utc::now(),
1090 );
1091 }
1092 }
1093 }
1094 });
1095 *guard = Some(PollerTask { token, handle });
1096 }
1097
1098 pub fn start_rate_limit_poller(&self) {
1116 self.start_rate_limit_poller_with(
1119 rate_limit_poll_interval(),
1120 crate::pr_status::resolve_gh_binary(),
1121 );
1122 }
1123
1124 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1129 if tokio::runtime::Handle::try_current().is_err() {
1130 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1131 return;
1132 }
1133 let mut guard = self
1134 .rate_limit_poller
1135 .lock()
1136 .unwrap_or_else(PoisonError::into_inner);
1137 if guard.is_some() {
1138 return;
1139 }
1140 let token = CancellationToken::new();
1141 let loop_token = token.clone();
1142 let cache = self.rate_limit_cache.clone();
1143 let registry = self.registry.clone();
1144 let handle = tokio::spawn(async move {
1145 let mut prev: Option<RateLimitSnapshot> = None;
1148 loop {
1149 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1157 let bin = gh_bin.clone();
1163 let resolved =
1164 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1165 .await
1166 .unwrap_or_else(|err| {
1167 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1168 });
1169 match resolved {
1170 Ok(snap) => {
1171 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1172 let summary = snap.summary_line();
1178 tracing::warn!(
1179 "GitHub API rate limit high: {summary} (querying \
1180 /rate_limit is free; the daemon's gh usage is not)"
1181 );
1182 }
1183 cache.replace(snap);
1188 prev = Some(snap);
1189 }
1190 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1195 }
1196 }
1197 tokio::select! {
1198 () = loop_token.cancelled() => break,
1199 () = tokio::time::sleep(interval) => {}
1200 }
1201 }
1202 });
1203 *guard = Some(PollerTask { token, handle });
1204 }
1205
1206 async fn close(&self, req: CloseRequest) -> Result<Value> {
1221 let entries = self.registry.list();
1225 let scan_path = req.path.clone();
1226 let open_windows =
1227 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1228 .await
1229 .unwrap_or_default();
1230 let open = !open_windows.is_empty();
1231 let window_key = open_windows.first().map(|(k, _)| k.clone());
1232 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1233
1234 if req.remove && !req.confirmed {
1238 let path = req.path.clone();
1239 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1240 .await
1241 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1242 .and_then(|inner| inner)
1243 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1244 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1250 return Ok(serde_json::to_value(SafetyReport {
1251 removable: git.removable,
1252 is_main: git.is_main,
1253 open,
1254 window_key,
1255 window_folder_count,
1256 risks: git.risks,
1257 info: git.info,
1258 })
1259 .unwrap_or_else(|_| json!({})));
1260 }
1261
1262 let others: Vec<String> = open_windows
1269 .iter()
1270 .map(|(k, _)| k.clone())
1271 .filter(|k| req.requester_key.as_deref() != Some(k))
1272 .collect();
1273 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1278 log_executing(
1279 &req.path,
1280 req.requester_key.as_deref(),
1281 req.remove,
1282 self_close,
1283 others.len(),
1284 );
1285 for key in &others {
1286 self.registry.mark_close_pending(key);
1287 }
1288 if !others.is_empty() {
1289 if let Err(err) = await_windows_closed(
1290 &self.registry,
1291 &req.path,
1292 req.requester_key.as_deref(),
1293 CLOSE_WAIT_TIMEOUT,
1294 CLOSE_WAIT_POLL,
1295 )
1296 .await
1297 {
1298 log_close_abort(&req.path, &err);
1299 return Err(err);
1300 }
1301 }
1302
1303 if req.remove {
1304 let path = req.path.clone();
1305 let entries = self.registry.list();
1309 let _guard = self.prune_lock.lock().await;
1315 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1316 .await
1317 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1318 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1319 log_and_map_removal(&req.path, removed)
1322 } else {
1323 log_window_closed(&req.path);
1326 Ok(json!({ "closed": true }))
1327 }
1328 }
1329
1330 fn reload(&self, req: ReloadRequest) -> Value {
1346 let live: HashSet<String> = self
1347 .registry
1348 .list()
1349 .into_iter()
1350 .map(|entry| entry.key)
1351 .collect();
1352
1353 let mut seen = HashSet::new();
1354 let mut signalled = 0usize;
1355 let mut unknown = Vec::new();
1356 for key in &req.target_keys {
1357 if !seen.insert(key.as_str()) {
1359 continue;
1360 }
1361 if live.contains(key) {
1362 self.registry.mark_reload_pending(key);
1363 signalled += 1;
1364 } else {
1365 unknown.push(key.clone());
1366 }
1367 }
1368
1369 log_reload(seen.len(), signalled, &unknown);
1370 json!({
1371 "requested": seen.len(),
1372 "signalled": signalled,
1373 "unknown": unknown,
1374 })
1375 }
1376
1377 async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1393 self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1398 .await
1399 }
1400
1401 async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1404 let report_only = req.check || !req.confirmed;
1407
1408 let eval_bin = bin.clone();
1409 let eval_paths = req.paths.clone();
1410 let (eligible, skipped) =
1411 tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1412 .await
1413 .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1414 .and_then(|inner| inner)?;
1415
1416 if report_only {
1417 log_merge_check(&req, eligible.len(), skipped.len());
1419 let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1420 return Ok(
1421 serde_json::to_value(EligibilityReport { eligible, skipped })
1422 .unwrap_or_else(|_| json!({})),
1423 );
1424 }
1425
1426 let enqueue_bin = bin.clone();
1428 let (queued, failed) =
1429 tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1430 .await
1431 .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1432 log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1433 Ok(serde_json::to_value(EnqueueResult {
1434 queued,
1435 skipped,
1436 failed,
1437 })
1438 .unwrap_or_else(|_| json!({})))
1439 }
1440
1441 async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1467 self.rebase_with(req, crate::git::resolve_git_binary())
1471 .await
1472 }
1473
1474 async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1477 if req.paths.is_empty() {
1478 bail!("`rebase` requires at least one path");
1479 }
1480 let report_only = req.check || !req.confirmed;
1483 let opts = req.options(git_bin);
1484 let selection = Selection::Paths(req.paths.clone());
1485
1486 if report_only {
1487 let plan = plan_rebase(&selection, &opts).await?;
1490 log_rebase_check(&req, &plan);
1492 return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1493 }
1494
1495 let _guard = self.rebase_lock.lock().await;
1508 let plan = plan_rebase(&selection, &opts).await?;
1509 let pending: Vec<PathBuf> = plan
1513 .worktrees
1514 .iter()
1515 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1516 .map(|w| canonical(&w.path))
1517 .collect();
1518 self.registry.mark_rebasing(&pending);
1519 let fetches = plan.fetches.clone();
1520 let exec_opts = opts.clone();
1521 let outcomes =
1522 tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1523 self.registry.clear_rebasing(&pending);
1526 let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1527
1528 log_rebase_execute(&req, &outcomes);
1529 Ok(rebase_reply(&fetches, &outcomes))
1530 }
1531
1532 async fn push(&self, req: PushRequest) -> Result<Value> {
1561 self.push_with(req, crate::git::resolve_git_binary()).await
1564 }
1565
1566 async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1569 if req.paths.is_empty() {
1570 bail!("`push` requires at least one path");
1571 }
1572 let report_only = req.check || !req.confirmed;
1575 let selection = Selection::Paths(req.paths.clone());
1576
1577 if report_only {
1578 let plan = plan_push(&selection).await?;
1582 log_push_check(&req, &plan);
1584 return Ok(push_reply(&plan.worktrees));
1585 }
1586
1587 let _guard = self.push_lock.lock().await;
1593 let plan = plan_push(&selection).await?;
1594 let pending: Vec<PathBuf> = plan
1598 .worktrees
1599 .iter()
1600 .filter(|w| w.result.is_pending())
1601 .map(|w| canonical(&w.path))
1602 .collect();
1603 self.registry.mark_pushing(&pending);
1604 let opts = worktree_push::PushOptions {
1605 git_bin: Some(git_bin),
1606 };
1607 let outcomes =
1608 tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1609 self.registry.clear_pushing(&pending);
1613 let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1614
1615 log_push_execute(&req, &outcomes);
1616 Ok(push_reply(&outcomes))
1617 }
1618
1619 async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1637 self.reposition_with(req, geometry::ax::AxBackend::new)
1638 .await
1639 }
1640
1641 async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1650 where
1651 B: geometry::WindowBackend,
1652 F: FnOnce() -> B + Send + 'static,
1653 {
1654 if req.reference_key.trim().is_empty() {
1655 bail!("`reposition` requires a non-empty `reference_key`");
1656 }
1657 let entries = self.registry.list();
1660 let reference = registered_window(&entries, &req.reference_key);
1661 if !reference.live {
1662 bail!(
1665 "no open window with key {} (it may have closed)",
1666 req.reference_key
1667 );
1668 }
1669 let targets: Vec<geometry::RegisteredWindow> = req
1672 .target_keys
1673 .iter()
1674 .map(|key| registered_window(&entries, key))
1675 .collect();
1676
1677 let check = req.check;
1678 let mut report = tokio::task::spawn_blocking(move || {
1679 let backend = make_backend();
1682 geometry::reposition(&backend, &reference, &targets, check)
1683 })
1684 .await
1685 .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1686
1687 let undo = std::mem::take(&mut report.undo);
1692 let undoable = !check && !undo.is_empty();
1693 if undoable {
1694 *self
1695 .reposition_undo
1696 .lock()
1697 .unwrap_or_else(PoisonError::into_inner) = undo;
1698 }
1699 log_reposition(&req, &report);
1700 Ok(reposition_reply(&report, undoable))
1701 }
1702
1703 async fn reposition_undo(&self) -> Result<Value> {
1711 self.reposition_undo_with(geometry::ax::AxBackend::new)
1712 .await
1713 }
1714
1715 async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1719 where
1720 B: geometry::WindowBackend,
1721 F: FnOnce() -> B + Send + 'static,
1722 {
1723 let stored = std::mem::take(
1724 &mut *self
1725 .reposition_undo
1726 .lock()
1727 .unwrap_or_else(PoisonError::into_inner),
1728 );
1729 if stored.is_empty() {
1730 return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1731 }
1732 let entries = self.registry.list();
1733 let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1734 .into_iter()
1735 .map(|(key, frame)| (registered_window(&entries, &key), frame))
1736 .collect();
1737
1738 let report = tokio::task::spawn_blocking(move || {
1739 let backend = make_backend();
1740 geometry::restore(&backend, &restore)
1741 })
1742 .await
1743 .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1744 log_reposition_undo(&report);
1745 Ok(reposition_reply(&report, false))
1746 }
1747}
1748
1749impl Default for WorktreesService {
1750 fn default() -> Self {
1751 Self::new()
1752 }
1753}
1754
1755#[async_trait]
1756impl DaemonService for WorktreesService {
1757 fn name(&self) -> &'static str {
1758 SERVICE_NAME
1759 }
1760
1761 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1762 match op {
1763 "register" => {
1764 let req: RegisterRequest =
1765 serde_json::from_value(payload).context("invalid `register` payload")?;
1766 if req.key.trim().is_empty() {
1767 bail!("`register` requires a non-empty `key`");
1768 }
1769 self.registry.register(req);
1770 Ok(json!({ "ok": true }))
1771 }
1772 "heartbeat" => {
1773 let key = require_str(&payload, "key", "heartbeat")?;
1774 let known = self.registry.heartbeat(key);
1775 let mut reply = json!({ "known": known });
1780 if self.registry.take_close_pending(key) {
1781 reply["close"] = Value::Bool(true);
1782 }
1783 if self.registry.take_reload_pending(key) {
1790 reply["reload"] = Value::Bool(true);
1791 }
1792 Ok(reply)
1793 }
1794 "unregister" => {
1795 let key = require_str(&payload, "key", "unregister")?;
1796 Ok(json!({ "removed": self.registry.unregister(key) }))
1797 }
1798 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1799 "tree" => {
1800 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1808 }
1809 "ahead-behind" => {
1810 let paths = payload
1817 .get("paths")
1818 .and_then(Value::as_array)
1819 .map(|arr| {
1820 arr.iter()
1821 .filter_map(Value::as_str)
1822 .map(PathBuf::from)
1823 .collect::<Vec<_>>()
1824 })
1825 .unwrap_or_default();
1826 Ok(json!({ "results": ahead_behind_results(&self.ahead_behind, paths).await }))
1827 }
1828 "set-show-closed" => {
1829 let show_closed = payload
1834 .get("show_closed")
1835 .and_then(Value::as_bool)
1836 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1837 self.registry.set_show_closed(show_closed);
1838 Ok(json!({ "ok": true }))
1839 }
1840 "set-polling" => {
1841 let owner = require_str(&payload, "owner", "set-polling")?;
1850 let name = require_str(&payload, "name", "set-polling")?;
1851 let enabled = payload
1852 .get("enabled")
1853 .and_then(Value::as_bool)
1854 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1855 if owner.trim().is_empty() || name.trim().is_empty() {
1856 bail!("`set-polling` requires a non-empty `owner` and `name`");
1857 }
1858 if self.registry.set_polling(owner, name, enabled) {
1859 self.persist_polling_prefs();
1860 }
1861 Ok(json!({ "ok": true }))
1862 }
1863 "open-prs" => {
1864 let owner = require_str(&payload, "owner", "open-prs")?;
1872 let name = require_str(&payload, "name", "open-prs")?;
1873 if owner.trim().is_empty() || name.trim().is_empty() {
1874 bail!("`open-prs` requires a non-empty `owner` and `name`");
1875 }
1876 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1877 }
1878 "open" => {
1879 let path = require_str(&payload, "path", "open")?;
1888 focus_window(Path::new(path))?;
1889 Ok(json!({ "ok": true }))
1890 }
1891 "close" => {
1892 let req: CloseRequest =
1898 serde_json::from_value(payload).context("invalid `close` payload")?;
1899 self.close(req).await
1900 }
1901 "reload" => {
1902 let req: ReloadRequest =
1910 serde_json::from_value(payload).context("invalid `reload` payload")?;
1911 Ok(self.reload(req))
1912 }
1913 "merge-queue" => {
1914 let req: MergeQueueRequest =
1921 serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1922 self.merge_queue(req).await
1923 }
1924 "rebase" => {
1925 let req: RebaseRequest =
1934 serde_json::from_value(payload).context("invalid `rebase` payload")?;
1935 self.rebase(req).await
1936 }
1937 "push" => {
1938 let req: PushRequest =
1948 serde_json::from_value(payload).context("invalid `push` payload")?;
1949 self.push(req).await
1950 }
1951 "reposition" => {
1952 let req: RepositionRequest =
1960 serde_json::from_value(payload).context("invalid `reposition` payload")?;
1961 self.reposition(req).await
1962 }
1963 "reposition-undo" => {
1964 self.reposition_undo().await
1968 }
1969 other => bail!("unknown worktrees op: {other}"),
1970 }
1971 }
1972
1973 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1974 if op != "subscribe" {
1977 return None;
1978 }
1979 Some(Box::new(WorktreesStream {
1980 cache: self.tree_cache.clone(),
1983 changes: self.registry.subscribe_changes(),
1986 }))
1987 }
1988
1989 fn menu(&self) -> MenuSnapshot {
1990 let cached = self
1995 .menu_cache
1996 .lock()
1997 .unwrap_or_else(PoisonError::into_inner)
1998 .clone();
1999 let items = cached.unwrap_or_else(|| {
2000 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
2001 });
2002 MenuSnapshot {
2003 title: SUBMENU_TITLE.to_string(),
2004 items,
2005 }
2006 }
2007
2008 async fn menu_action(&self, action_id: &str) -> Result<()> {
2009 if let Some(key) = action_id.strip_prefix("focus:") {
2010 let folder = self
2013 .registry
2014 .first_folder(key)
2015 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2016 focus_window(&folder)?;
2017 return Ok(());
2018 }
2019 bail!("unknown worktrees menu action: {action_id}")
2020 }
2021
2022 async fn status(&self) -> ServiceStatus {
2023 let entries = self.registry.list();
2024 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2025 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2026 let windows = enriched_windows(entries).await;
2027 ServiceStatus {
2028 name: SERVICE_NAME.to_string(),
2029 healthy: true,
2030 summary,
2031 detail: json!({ "windows": windows }),
2032 }
2033 }
2034
2035 fn credit_accept_outage(&self, outage: Duration) {
2036 self.registry.credit_outage(outage);
2037 }
2038
2039 async fn shutdown(&self) {
2040 let task = self
2044 .refresh
2045 .lock()
2046 .unwrap_or_else(PoisonError::into_inner)
2047 .take();
2048 if let Some(task) = task {
2049 task.token.cancel();
2050 let _ = task.handle.await;
2051 }
2052 let poller = self
2055 .poller
2056 .lock()
2057 .unwrap_or_else(PoisonError::into_inner)
2058 .take();
2059 if let Some(poller) = poller {
2060 poller.token.cancel();
2061 let _ = poller.handle.await;
2062 }
2063 let rate_limit_poller = self
2065 .rate_limit_poller
2066 .lock()
2067 .unwrap_or_else(PoisonError::into_inner)
2068 .take();
2069 if let Some(poller) = rate_limit_poller {
2070 poller.token.cancel();
2071 let _ = poller.handle.await;
2072 }
2073 }
2074}
2075
2076fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2080 payload
2081 .get(field)
2082 .and_then(Value::as_str)
2083 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2084}
2085
2086#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2097struct GitStatus {
2098 #[serde(skip_serializing_if = "Option::is_none")]
2100 branch: Option<String>,
2101 #[serde(skip_serializing_if = "Option::is_none")]
2107 head_sha: Option<String>,
2108 #[serde(skip_serializing_if = "Option::is_none")]
2116 upstream_sha: Option<String>,
2117 #[serde(skip_serializing_if = "Option::is_none")]
2119 ahead: Option<usize>,
2120 #[serde(skip_serializing_if = "Option::is_none")]
2122 behind: Option<usize>,
2123 #[serde(skip_serializing_if = "Option::is_none")]
2128 main_repo: Option<String>,
2129 #[serde(skip_serializing_if = "is_false")]
2132 is_worktree: bool,
2133 #[serde(skip_serializing_if = "Option::is_none")]
2149 operation: Option<String>,
2150}
2151
2152#[allow(clippy::trivially_copy_pass_by_ref)]
2156fn is_false(b: &bool) -> bool {
2157 !*b
2158}
2159
2160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2165struct PollingLease {
2166 repo: String,
2167 expires_at: DateTime<Utc>,
2168}
2169
2170#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2176struct PollingPrefs {
2177 #[serde(default)]
2178 enabled: Vec<PollingLease>,
2179}
2180
2181fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2185 if let Some(parent) = path.parent() {
2186 crate::daemon::paths::ensure_dir_0700(parent)?;
2187 }
2188 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2189 crate::daemon::paths::write_file_0600(path, &json)
2190}
2191
2192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2205struct PersistedBadge {
2206 number: u64,
2207 is_draft: bool,
2208 checks: PrCheckState,
2209 url: String,
2210 head_oid: String,
2211}
2212
2213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2218enum PersistedResolution {
2219 Pr(PersistedBadge),
2220 NoPr,
2221}
2222
2223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2225struct PersistedEntry {
2226 target: PrTarget,
2227 resolution: PersistedResolution,
2228}
2229
2230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2235struct PersistedWatch {
2236 target: PrTarget,
2237 #[serde(default, skip_serializing_if = "Option::is_none")]
2238 upstream_sha: Option<String>,
2239}
2240
2241#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2246struct PrCachePrefs {
2247 #[serde(default)]
2248 entries: Vec<PersistedEntry>,
2249 #[serde(default)]
2250 watched: Vec<PersistedWatch>,
2251 #[serde(default, skip_serializing_if = "Option::is_none")]
2252 polled_at: Option<DateTime<Utc>>,
2253}
2254
2255impl PersistedResolution {
2256 fn from_resolution(r: &PrResolution) -> Self {
2258 match r {
2259 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2260 number: b.number,
2261 is_draft: b.is_draft,
2262 checks: b.checks,
2263 url: b.url.clone(),
2264 head_oid: b.head_oid.clone(),
2265 }),
2266 PrResolution::NoPr => Self::NoPr,
2267 }
2268 }
2269
2270 fn into_resolution(self) -> PrResolution {
2272 match self {
2273 Self::Pr(b) => PrResolution::Pr(PrBadge {
2274 number: b.number,
2275 is_draft: b.is_draft,
2276 checks: b.checks,
2277 url: b.url,
2278 head_oid: b.head_oid,
2279 }),
2280 Self::NoPr => PrResolution::NoPr,
2281 }
2282 }
2283}
2284
2285fn pr_cache_prefs_from(
2288 entries: Vec<(PrTarget, PrResolution)>,
2289 watched: &[PrWatch],
2290 polled_at: DateTime<Utc>,
2291) -> PrCachePrefs {
2292 let mut entries: Vec<PersistedEntry> = entries
2293 .into_iter()
2294 .map(|(target, resolution)| PersistedEntry {
2295 target,
2296 resolution: PersistedResolution::from_resolution(&resolution),
2297 })
2298 .collect();
2299 entries.sort_by(|a, b| a.target.cmp(&b.target));
2302 let mut watched: Vec<PersistedWatch> = watched
2303 .iter()
2304 .map(|w| PersistedWatch {
2305 target: w.target.clone(),
2306 upstream_sha: w.upstream_sha.clone(),
2307 })
2308 .collect();
2309 watched.sort_by(|a, b| a.target.cmp(&b.target));
2310 PrCachePrefs {
2311 entries,
2312 watched,
2313 polled_at: Some(polled_at),
2314 }
2315}
2316
2317fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2320 if let Some(parent) = path.parent() {
2321 crate::daemon::paths::ensure_dir_0700(parent)?;
2322 }
2323 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2324 crate::daemon::paths::write_file_0600(path, &json)
2325}
2326
2327fn persist_pr_cache(
2333 path: &Path,
2334 pr_cache: &PrStatusCache,
2335 watched: &[PrWatch],
2336 polled_at: DateTime<Utc>,
2337) {
2338 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2339 if let Err(err) = write_pr_cache(path, &prefs) {
2340 let at = path.display();
2341 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2342 }
2343}
2344
2345#[derive(Debug, Clone)]
2351struct PrWarmStart {
2352 watched: Vec<PrWatch>,
2354 polled_at: DateTime<Utc>,
2357}
2358
2359#[derive(Debug, Clone)]
2364struct OpenPrEntry {
2365 at: Instant,
2366 prs: Vec<Value>,
2367}
2368
2369#[derive(Debug)]
2380struct OpenPrCache {
2381 entries: Mutex<HashMap<String, OpenPrEntry>>,
2382 ttl: Duration,
2383}
2384
2385impl OpenPrCache {
2386 fn new(ttl: Duration) -> Self {
2387 Self {
2388 entries: Mutex::new(HashMap::new()),
2389 ttl,
2390 }
2391 }
2392
2393 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2396 self.entries
2397 .lock()
2398 .unwrap_or_else(PoisonError::into_inner)
2399 .get(key)
2400 .filter(|e| e.at.elapsed() < self.ttl)
2401 .map(|e| e.prs.clone())
2402 }
2403
2404 fn store(&self, key: String, prs: Vec<Value>) {
2406 self.entries
2407 .lock()
2408 .unwrap_or_else(PoisonError::into_inner)
2409 .insert(
2410 key,
2411 OpenPrEntry {
2412 at: Instant::now(),
2413 prs,
2414 },
2415 );
2416 }
2417}
2418
2419fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2424 let output = crate::github_metrics::run_gh(
2425 bin,
2426 [
2427 "pr",
2428 "list",
2429 "--repo",
2430 slug,
2431 "--state",
2432 "open",
2433 "--json",
2434 OPEN_PR_JSON_FIELDS,
2435 "--limit",
2436 OPEN_PR_LIST_LIMIT,
2437 ],
2438 "pr list",
2439 None,
2440 )
2441 .with_context(|| {
2442 format!(
2443 "failed to run {} (is the GitHub CLI installed?)",
2444 bin.display()
2445 )
2446 })?;
2447 if !output.status.success() {
2448 let stderr = String::from_utf8_lossy(&output.stderr);
2449 bail!("gh pr list failed: {}", stderr.trim());
2450 }
2451 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2452 Value::Array(arr) => Ok(arr),
2453 _ => bail!("gh pr list did not return a JSON array"),
2454 }
2455}
2456
2457fn git_status(folder: &Path) -> GitStatus {
2463 git_status_impl(folder, true)
2464}
2465
2466fn git_status_cheap(folder: &Path) -> GitStatus {
2474 git_status_impl(folder, false)
2475}
2476
2477fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2483 let Ok(repo) = Repository::discover(folder) else {
2484 return GitStatus::default();
2485 };
2486 let base = GitStatus {
2492 main_repo: main_repo_name(repo.commondir()),
2493 is_worktree: repo.is_worktree(),
2494 operation: operation_slug(repo.state()),
2495 ..GitStatus::default()
2496 };
2497 let Ok(head) = repo.head() else {
2498 return base;
2500 };
2501 let base = GitStatus {
2507 head_sha: head.target().map(|oid| oid.to_string()),
2508 ..base
2509 };
2510 let Some(name) = head
2514 .shorthand()
2515 .ok()
2516 .filter(|_| head.is_branch())
2517 .map(str::to_string)
2518 else {
2519 return base;
2520 };
2521 let branch = git2::Branch::wrap(head);
2526 let upstream_sha = upstream_target(&branch);
2529 let (ahead, behind) = if with_ahead_behind {
2532 match upstream_ahead_behind(&repo, &branch) {
2533 Some((ahead, behind)) => (Some(ahead), Some(behind)),
2534 None => (None, None),
2535 }
2536 } else {
2537 (None, None)
2538 };
2539 GitStatus {
2540 branch: Some(name),
2541 upstream_sha,
2542 ahead,
2543 behind,
2544 ..base
2545 }
2546}
2547
2548fn operation_slug(state: RepositoryState) -> Option<String> {
2559 let slug = match state {
2560 RepositoryState::Clean => return None,
2561 RepositoryState::Merge => "merge",
2562 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2563 RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2564 RepositoryState::Bisect => "bisect",
2565 RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2566 RepositoryState::RebaseInteractive => "rebase-interactive",
2567 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2568 };
2569 Some(slug.to_string())
2570}
2571
2572fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2582 Some(branch.upstream().ok()?.get().target()?.to_string())
2583}
2584
2585fn repo_ahead_behind(repo: &Repository) -> Option<(usize, usize)> {
2595 let head = repo.head().ok()?;
2596 if !head.is_branch() {
2597 return None;
2598 }
2599 let branch = git2::Branch::wrap(head);
2600 upstream_ahead_behind(repo, &branch)
2601}
2602
2603fn repo_main_behind(repo: &Repository) -> Option<usize> {
2617 let head = repo.head().ok()?;
2618 if !head.is_branch() {
2619 return None;
2620 }
2621 let branch = git2::Branch::wrap(head);
2622
2623 let remote = "origin";
2624 let default_branch = RemoteInfo::detect_main_branch_local(repo, remote)?;
2625 let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2626
2627 if let Ok(upstream) = branch.upstream() {
2632 if upstream.get().name() == Ok(onto_ref.as_str()) {
2633 return None;
2634 }
2635 }
2636
2637 let head_oid = branch.get().target()?;
2638 let onto_oid = repo
2639 .revparse_single(&onto_ref)
2640 .ok()?
2641 .peel_to_commit()
2642 .ok()?
2643 .id();
2644 let (_ahead, behind) = divergence::graph_ahead_behind(repo, head_oid, onto_oid)?;
2645 Some(behind)
2646}
2647
2648fn folder_divergence(folder: &Path) -> Divergence {
2651 let Ok(repo) = Repository::discover(folder) else {
2652 return Divergence::default();
2653 };
2654 Divergence {
2655 ahead_behind: repo_ahead_behind(&repo),
2656 main_behind: repo_main_behind(&repo),
2657 }
2658}
2659
2660fn main_repo_name(commondir: &Path) -> Option<String> {
2666 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2667 if file_name == ".git" {
2668 commondir
2670 .parent()
2671 .and_then(Path::file_name)
2672 .map(|n| n.to_string_lossy().into_owned())
2673 } else {
2674 Some(
2676 file_name
2677 .strip_suffix(".git")
2678 .unwrap_or(&file_name)
2679 .to_string(),
2680 )
2681 }
2682}
2683
2684fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2687 let upstream = branch.upstream().ok()?;
2688 let local_oid = branch.get().target()?;
2689 let upstream_oid = upstream.get().target()?;
2690 divergence::graph_ahead_behind(repo, local_oid, upstream_oid)
2691}
2692
2693#[derive(Serialize)]
2699struct EnrichedEntry<'a> {
2700 #[serde(flatten)]
2701 entry: &'a WindowEntry,
2702 #[serde(flatten)]
2703 git: GitStatus,
2704}
2705
2706fn enriched_entry(entry: &WindowEntry) -> Value {
2711 let git = entry
2712 .folders
2713 .first()
2714 .map(|folder| git_status(folder))
2715 .unwrap_or_default();
2716 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2717}
2718
2719async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2723 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2724 .await
2725 .unwrap_or_default()
2726}
2727
2728#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2734struct GithubIdentity {
2735 owner: String,
2737 name: String,
2739}
2740
2741#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2757struct TreeWorktree {
2758 path: String,
2760 #[serde(skip_serializing_if = "Option::is_none")]
2762 branch: Option<String>,
2763 #[serde(skip_serializing_if = "Option::is_none")]
2768 head_sha: Option<String>,
2769 #[serde(skip_serializing_if = "Option::is_none")]
2775 upstream_sha: Option<String>,
2776 is_main: bool,
2778 open: bool,
2780 #[serde(skip_serializing_if = "Option::is_none")]
2783 window_key: Option<String>,
2784 #[serde(skip_serializing_if = "Option::is_none")]
2790 pr: Option<PrBadge>,
2791 #[serde(skip_serializing_if = "is_false")]
2801 pr_none: bool,
2802 #[serde(skip_serializing_if = "Option::is_none")]
2807 operation: Option<String>,
2808 #[serde(skip_serializing_if = "is_false")]
2817 rebasing: bool,
2818 #[serde(skip_serializing_if = "is_false")]
2827 pushing: bool,
2828}
2829
2830#[derive(Debug, Clone, Default)]
2838struct InFlight {
2839 rebasing: HashSet<PathBuf>,
2841 pushing: HashSet<PathBuf>,
2843}
2844
2845impl InFlight {
2846 fn read(registry: &WorktreesRegistry) -> Self {
2850 Self {
2851 rebasing: registry.rebasing_paths(),
2852 pushing: registry.pushing_paths(),
2853 }
2854 }
2855}
2856
2857#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2861struct TreeRepo {
2862 main_repo: String,
2864 #[serde(skip_serializing_if = "Option::is_none")]
2866 github: Option<GithubIdentity>,
2867 root: String,
2869 #[serde(skip_serializing_if = "is_false")]
2877 polling_enabled: bool,
2878 worktrees: Vec<TreeWorktree>,
2881}
2882
2883fn github_identity(url: &str) -> Option<GithubIdentity> {
2890 let url = url.trim();
2891 let rest = [
2893 "https://github.com/",
2894 "http://github.com/",
2895 "ssh://git@github.com/",
2896 "git://github.com/",
2897 "git@github.com:",
2898 ]
2899 .iter()
2900 .find_map(|prefix| url.strip_prefix(prefix))?;
2901 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2902 let rest = rest.trim_end_matches('/');
2903 let mut parts = rest.splitn(2, '/');
2904 let owner = parts.next()?.trim();
2905 let name = parts.next()?.trim();
2906 if owner.is_empty() || name.is_empty() || name.contains('/') {
2908 return None;
2909 }
2910 Some(GithubIdentity {
2911 owner: owner.to_string(),
2912 name: name.to_string(),
2913 })
2914}
2915
2916fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2919 if let Ok(origin) = repo.find_remote("origin") {
2920 if let Some(id) = origin.url().ok().and_then(github_identity) {
2921 return Some(id);
2922 }
2923 }
2924 let names = repo.remotes().ok();
2928 names
2929 .iter()
2930 .flat_map(|arr| arr.iter())
2931 .flatten()
2932 .flatten()
2933 .filter_map(|name| repo.find_remote(name).ok())
2934 .find_map(|remote| remote.url().ok().and_then(github_identity))
2935}
2936
2937fn canonical(path: &Path) -> PathBuf {
2941 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2942}
2943
2944fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2949 let mut index = HashMap::new();
2950 for entry in entries {
2951 for folder in &entry.folders {
2952 index
2953 .entry(canonical(folder))
2954 .or_insert_with(|| entry.key.clone());
2955 }
2956 }
2957 index
2958}
2959
2960fn worktree_entry(
2965 path: &Path,
2966 is_main: bool,
2967 open_index: &HashMap<PathBuf, String>,
2968 in_flight: &InFlight,
2969) -> TreeWorktree {
2970 let status = git_status_cheap(path);
2971 let canonical = canonical(path);
2972 let window_key = open_index.get(&canonical).cloned();
2973 TreeWorktree {
2974 path: path.display().to_string(),
2975 branch: status.branch,
2976 head_sha: status.head_sha,
2977 upstream_sha: status.upstream_sha,
2978 is_main,
2979 open: window_key.is_some(),
2980 window_key,
2981 pr: None,
2984 pr_none: false,
2985 operation: status.operation,
2986 rebasing: in_flight.rebasing.contains(&canonical),
2989 pushing: in_flight.pushing.contains(&canonical),
2990 }
2991}
2992
2993fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3022 for repo in repos {
3023 if let Some(github) = &repo.github {
3024 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3025 }
3026 }
3027}
3028
3029fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3030 for repo in repos {
3031 if !repo.polling_enabled {
3035 continue;
3036 }
3037 let Some(github) = repo.github.clone() else {
3038 continue;
3039 };
3040 for worktree in &mut repo.worktrees {
3041 let Some(branch) = &worktree.branch else {
3042 continue;
3043 };
3044 match pr_cache.get(&github.owner, &github.name, branch) {
3045 Some(PrResolution::Pr(mut badge)) => {
3046 if badge.is_stale_for(worktree.head_sha.as_deref()) {
3047 badge.checks = PrCheckState::Pending;
3048 }
3049 worktree.pr = Some(badge);
3050 }
3051 Some(PrResolution::NoPr) => worktree.pr_none = true,
3052 None => {}
3053 }
3054 }
3055 }
3056}
3057
3058fn repo_tree(
3064 discovered: &Repository,
3065 open_index: &HashMap<PathBuf, String>,
3066 in_flight: &InFlight,
3067) -> Option<TreeRepo> {
3068 let commondir = canonical(discovered.commondir());
3071 let main_root = commondir.parent()?.to_path_buf();
3072 let main_repo = Repository::open(&main_root).ok()?;
3073
3074 let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3076 let names = main_repo.worktrees().ok();
3081 let mut linked: Vec<PathBuf> = names
3082 .iter()
3083 .flat_map(|arr| arr.iter())
3084 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
3087 .map(|wt| wt.path().to_path_buf())
3088 .collect();
3089 linked.sort();
3090 worktrees.extend(
3091 linked
3092 .iter()
3093 .map(|path| worktree_entry(path, false, open_index, in_flight)),
3094 );
3095
3096 Some(TreeRepo {
3097 main_repo: main_repo_name(&commondir)?,
3098 github: remote_github_identity(&main_repo),
3099 root: main_root.display().to_string(),
3100 polling_enabled: false,
3103 worktrees,
3104 })
3105}
3106
3107fn build_tree(
3113 folders: Vec<PathBuf>,
3114 windows: Vec<WindowEntry>,
3115 in_flight: InFlight,
3116) -> Vec<TreeRepo> {
3117 let open_index = open_window_index(&windows);
3118 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3119 for folder in &folders {
3120 let Ok(repo) = Repository::discover(folder) else {
3121 continue;
3122 };
3123 let key = canonical(repo.commondir());
3124 if repos.contains_key(&key) {
3125 continue;
3126 }
3127 if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3128 repos.insert(key, tree);
3129 }
3130 }
3131 repos.into_values().collect()
3132}
3133
3134async fn tree_repos(
3139 folders: Vec<PathBuf>,
3140 windows: Vec<WindowEntry>,
3141 pr_cache: Arc<PrStatusCache>,
3142 enabled_polling: HashSet<String>,
3143 in_flight: InFlight,
3144) -> Vec<Value> {
3145 tokio::task::spawn_blocking(move || {
3146 let mut repos = build_tree(folders, windows, in_flight);
3147 stamp_polling(&mut repos, &enabled_polling);
3150 fold_pr_badges(&mut repos, &pr_cache);
3151 repos
3152 .iter()
3153 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3154 .collect()
3155 })
3156 .await
3157 .unwrap_or_default()
3158}
3159
3160#[derive(Serialize)]
3169struct AheadBehindEntry {
3170 #[serde(skip_serializing_if = "Option::is_none")]
3171 ahead: Option<usize>,
3172 #[serde(skip_serializing_if = "Option::is_none")]
3173 behind: Option<usize>,
3174 #[serde(skip_serializing_if = "Option::is_none")]
3175 main_behind: Option<usize>,
3176}
3177
3178async fn ahead_behind_results(coordinator: &AheadBehindCoordinator, paths: Vec<PathBuf>) -> Value {
3198 let divergences = futures::future::join_all(
3199 paths
3200 .iter()
3201 .map(|path| coordinator.get_or_compute(path.clone(), |p| folder_divergence(&p))),
3202 )
3203 .await;
3204 let mut results = serde_json::Map::new();
3205 for (path, divergence) in paths.iter().zip(divergences) {
3206 let (ahead, behind) = divergence
3207 .ahead_behind
3208 .map_or((None, None), |(a, b)| (Some(a), Some(b)));
3209 let main_behind = divergence.main_behind;
3210 if ahead.is_none() && main_behind.is_none() {
3211 continue;
3212 }
3213 results.insert(
3214 path.display().to_string(),
3215 json!(AheadBehindEntry {
3216 ahead,
3217 behind,
3218 main_behind,
3219 }),
3220 );
3221 }
3222 Value::Object(results)
3223}
3224
3225struct WorktreesStream {
3239 cache: Arc<TreeSnapshotCache>,
3242 changes: watch::Receiver<u64>,
3246}
3247
3248#[async_trait]
3249impl ServiceStream for WorktreesStream {
3250 async fn changed(&mut self) {
3251 if self.changes.changed().await.is_err() {
3257 std::future::pending::<()>().await;
3258 }
3259 }
3260
3261 async fn snapshot(&self) -> Value {
3262 self.cache.snapshot().await
3267 }
3268}
3269
3270struct TreeSnapshotCache {
3294 registry: Arc<WorktreesRegistry>,
3297 pr_cache: Arc<PrStatusCache>,
3300 ttl: Duration,
3304 state: AsyncMutex<Option<CachedTree>>,
3309 computes: AtomicU64,
3313}
3314
3315struct CachedTree {
3318 generation: u64,
3322 computed_at: Instant,
3324 value: Arc<Value>,
3327}
3328
3329impl TreeSnapshotCache {
3330 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3334 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3335 }
3336
3337 fn with_ttl(
3340 registry: Arc<WorktreesRegistry>,
3341 pr_cache: Arc<PrStatusCache>,
3342 ttl: Duration,
3343 ) -> Self {
3344 Self {
3345 registry,
3346 pr_cache,
3347 ttl,
3348 state: AsyncMutex::new(None),
3349 computes: AtomicU64::new(0),
3350 }
3351 }
3352
3353 async fn snapshot(&self) -> Value {
3357 let mut state = self.state.lock().await;
3362 let generation = self.registry.change_generation();
3363 let fresh = state.as_ref().and_then(|cached| {
3366 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3367 .then(|| Arc::clone(&cached.value))
3368 });
3369 let value = if let Some(value) = fresh {
3370 value
3371 } else {
3372 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3373 self.computes.fetch_add(1, Ordering::Relaxed);
3374 *state = Some(CachedTree {
3375 generation,
3376 computed_at: Instant::now(),
3377 value: Arc::clone(&value),
3378 });
3379 value
3380 };
3381 drop(state);
3383 (*value).clone()
3384 }
3385
3386 #[cfg(test)]
3389 fn compute_count(&self) -> u64 {
3390 self.computes.load(Ordering::Relaxed)
3391 }
3392}
3393
3394async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3400 let folders = registry.open_folders();
3401 let windows = registry.list();
3402 let show_closed = registry.show_closed();
3403 let enabled_polling = registry.enabled_polling_repos();
3404 let in_flight = InFlight::read(registry);
3407 json!({
3408 "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3409 "show_closed": show_closed,
3410 })
3411}
3412
3413fn display_name(entry: &WindowEntry) -> String {
3416 if let Some(repo) = &entry.repo {
3417 return repo.clone();
3418 }
3419 if let Some(folder) = entry.folders.first() {
3420 return folder.file_name().map_or_else(
3421 || folder.display().to_string(),
3422 |n| n.to_string_lossy().into_owned(),
3423 );
3424 }
3425 "(no folder)".to_string()
3426}
3427
3428const REPO_SEP: char = '·';
3430const WORKTREE_SEP: char = '⑂';
3433
3434fn menu_items_for(
3439 entries: &[WindowEntry],
3440 rate_limit: Option<&RateLimitSnapshot>,
3441) -> Vec<MenuItem> {
3442 let mut items = Vec::new();
3443 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3447 if !label.is_empty() {
3448 items.push(MenuItem::Label(label));
3449 items.push(MenuItem::Separator);
3450 }
3451 }
3452 if entries.is_empty() {
3453 items.push(MenuItem::Label("No open windows".to_string()));
3454 } else {
3455 items.extend(window_menu_items(entries));
3456 }
3457 items
3458}
3459
3460fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3467 entries
3468 .iter()
3469 .map(|entry| {
3470 let label = window_label(entry);
3471 if entry.folders.is_empty() {
3472 MenuItem::Label(label)
3473 } else {
3474 MenuItem::Action(MenuAction {
3475 id: format!("focus:{}", entry.key),
3476 label,
3477 enabled: true,
3478 })
3479 }
3480 })
3481 .collect()
3482}
3483
3484fn window_label(entry: &WindowEntry) -> String {
3490 let status = entry
3491 .folders
3492 .first()
3493 .map(|folder| git_status(folder))
3494 .unwrap_or_default();
3495 let name = status
3498 .main_repo
3499 .clone()
3500 .unwrap_or_else(|| display_name(entry));
3501 if let Some(branch) = &status.branch {
3502 let sep = if status.is_worktree {
3503 WORKTREE_SEP
3504 } else {
3505 REPO_SEP
3506 };
3507 return match sync_indicator(status.ahead, status.behind) {
3508 Some(sync) => format!("{name} {sep} {branch} {sync}"),
3509 None => format!("{name} {sep} {branch}"),
3510 };
3511 }
3512 match &entry.title {
3514 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3515 _ => name,
3516 }
3517}
3518
3519fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3522 match (ahead, behind) {
3523 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3524 _ => None,
3525 }
3526}
3527
3528const CODE_BINARY_CANDIDATES: &[&str] = &[
3531 "/usr/local/bin/code",
3532 "/opt/homebrew/bin/code",
3533 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3534 "/usr/bin/code",
3535];
3536
3537pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3542 focus_window_with(&resolve_code_binary(), folder)
3543}
3544
3545fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3552 if !folder.is_absolute() {
3557 bail!(
3558 "refusing to focus a non-absolute folder path: {}",
3559 folder.display()
3560 );
3561 }
3562 if !folder.is_dir() {
3563 bail!("worktree folder no longer exists: {}", folder.display());
3564 }
3565 let child = Command::new(program)
3568 .arg(folder)
3569 .stdin(Stdio::null())
3570 .stdout(Stdio::null())
3571 .stderr(Stdio::null())
3572 .spawn()
3573 .with_context(|| {
3574 format!(
3575 "failed to launch `{}` to focus {}",
3576 program.display(),
3577 folder.display()
3578 )
3579 })?;
3580 std::thread::spawn(move || {
3582 let mut child = child;
3583 let _ = child.wait();
3584 });
3585 Ok(())
3586}
3587
3588fn resolve_code_binary() -> PathBuf {
3593 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3594}
3595
3596fn resolve_code_binary_from(
3599 env_override: Option<std::ffi::OsString>,
3600 candidates: &[&str],
3601) -> PathBuf {
3602 if let Some(path) = env_override {
3603 return PathBuf::from(path);
3604 }
3605 for candidate in candidates {
3606 let path = Path::new(candidate);
3607 if path.exists() {
3608 return path.to_path_buf();
3609 }
3610 }
3611 PathBuf::from("code")
3612}
3613
3614#[derive(Debug, Clone, Deserialize)]
3624struct RepositionRequest {
3625 reference_key: String,
3627 #[serde(default)]
3630 target_keys: Vec<String>,
3631 #[serde(default)]
3634 check: bool,
3635}
3636
3637fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3644 entries.iter().find(|entry| entry.key == key).map_or_else(
3645 || geometry::RegisteredWindow {
3646 key: key.to_string(),
3647 live: false,
3648 title: None,
3649 pid: None,
3650 },
3651 |entry| geometry::RegisteredWindow {
3652 key: entry.key.clone(),
3653 live: true,
3654 title: entry.title.clone(),
3655 pid: entry.pid,
3656 },
3657 )
3658}
3659
3660fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3666 let mut reply = json!({
3667 "trusted": report.trusted,
3668 "results": report.results,
3669 "moved": report.moved(),
3670 "skipped": report.skipped(),
3671 });
3672 if let Some(reference) = &report.reference {
3673 reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3674 }
3675 if let Some(blocked) = &report.blocked {
3676 reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3677 }
3678 if undoable {
3681 reply["undoable"] = Value::Bool(true);
3682 }
3683 reply
3684}
3685
3686fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3690 let phase = if !report.trusted {
3693 "untrusted"
3694 } else if report.blocked.is_some() {
3695 "blocked"
3696 } else if req.check {
3697 "check"
3698 } else {
3699 "apply"
3700 };
3701 tracing::info!(
3702 phase,
3703 reference = req.reference_key.as_str(),
3704 requested = req.target_keys.len(),
3705 blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3706 moved = report.moved(),
3707 skipped = report.skipped(),
3708 outcomes = outcome_kinds(report).as_str(),
3709 "reposition"
3710 );
3711}
3712
3713fn log_reposition_undo(report: &geometry::RepositionReport) {
3715 tracing::info!(
3716 trusted = report.trusted,
3717 restored = report.moved(),
3718 skipped = report.skipped(),
3719 outcomes = outcome_kinds(report).as_str(),
3720 "reposition undo"
3721 );
3722}
3723
3724fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3728 if report.results.is_empty() {
3729 return "-".to_string();
3730 }
3731 report
3732 .results
3733 .iter()
3734 .map(|r| r.outcome)
3735 .collect::<Vec<_>>()
3736 .join(",")
3737}
3738
3739#[derive(Debug, Clone, Deserialize)]
3749struct ReloadRequest {
3750 #[serde(default)]
3754 target_keys: Vec<String>,
3755}
3756
3757fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3761 let unknown = if unknown.is_empty() {
3765 "-".to_string()
3766 } else {
3767 unknown.join(",")
3768 };
3769 tracing::info!(
3770 requested,
3771 signalled,
3772 unknown = %unknown,
3773 "worktrees reload: signalled windows"
3774 );
3775}
3776
3777#[derive(Debug, Clone, Deserialize)]
3783struct CloseRequest {
3784 path: PathBuf,
3786 #[serde(default)]
3790 requester_key: Option<String>,
3791 #[serde(default)]
3795 remove: bool,
3796 #[serde(default)]
3799 confirmed: bool,
3800}
3801
3802#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3807struct Note {
3808 kind: String,
3810 detail: String,
3812}
3813
3814impl Note {
3815 fn new(kind: &str, detail: impl Into<String>) -> Self {
3816 Self {
3817 kind: kind.to_string(),
3818 detail: detail.into(),
3819 }
3820 }
3821}
3822
3823fn note_kinds(notes: &[Note]) -> String {
3827 if notes.is_empty() {
3828 return "-".to_string();
3829 }
3830 notes
3831 .iter()
3832 .map(|n| n.kind.as_str())
3833 .collect::<Vec<_>>()
3834 .join(",")
3835}
3836
3837fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3842 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3843}
3844
3845fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3851 tracing::error!(
3852 path = %path.display(),
3853 "worktrees close: {phase} failed: {err:#}"
3854 );
3855 err
3856}
3857
3858fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3869 match removed {
3870 Ok(Removal::Pruned) => {
3871 tracing::info!(
3872 path = %path.display(),
3873 outcome = "pruned",
3874 "worktrees close: linked worktree pruned"
3875 );
3876 Ok(json!({ "removed": true }))
3877 }
3878 Ok(Removal::AlreadyGone) => {
3879 tracing::info!(
3880 path = %path.display(),
3881 outcome = "already-gone",
3882 "worktrees close: nothing to prune, worktree already removed"
3883 );
3884 Ok(json!({ "removed": true }))
3885 }
3886 Err(err) => {
3887 tracing::warn!(
3888 path = %path.display(),
3889 outcome = "failed",
3890 "worktrees close: worktree prune failed: {err:#}"
3891 );
3892 Err(err)
3893 }
3894 }
3895}
3896
3897fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3903 tracing::info!(
3904 path = %path.display(),
3905 window_key = window_key.unwrap_or("-"),
3906 removable = git.removable,
3907 is_main = git.is_main,
3908 open,
3909 risks = %note_kinds(&git.risks),
3910 "worktrees close: safety check"
3911 );
3912}
3913
3914fn log_executing(
3919 path: &Path,
3920 requester: Option<&str>,
3921 remove: bool,
3922 self_close: bool,
3923 cross_window: usize,
3924) {
3925 tracing::info!(
3926 path = %path.display(),
3927 requester = requester.unwrap_or("-"),
3928 remove,
3929 self_close,
3930 cross_window,
3931 "worktrees close: executing"
3932 );
3933}
3934
3935fn log_close_abort(path: &Path, err: &anyhow::Error) {
3939 tracing::warn!(
3940 path = %path.display(),
3941 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3942 );
3943}
3944
3945fn log_window_closed(path: &Path) {
3949 tracing::info!(
3950 path = %path.display(),
3951 "worktrees close: window closed, no removal"
3952 );
3953}
3954
3955#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3959struct SafetyReport {
3960 removable: bool,
3963 is_main: bool,
3965 open: bool,
3967 #[serde(skip_serializing_if = "Option::is_none")]
3969 window_key: Option<String>,
3970 window_folder_count: usize,
3973 risks: Vec<Note>,
3976 info: Vec<Note>,
3979}
3980
3981#[derive(Debug, Clone, PartialEq, Eq)]
3984struct GitSafety {
3985 is_main: bool,
3986 removable: bool,
3987 risks: Vec<Note>,
3988 info: Vec<Note>,
3989}
3990
3991#[derive(Debug, Clone, Deserialize)]
3999struct RebaseRequest {
4000 paths: Vec<PathBuf>,
4002 #[serde(default)]
4005 requester_key: Option<String>,
4006 #[serde(default)]
4008 check: bool,
4009 #[serde(default)]
4011 confirmed: bool,
4012 #[serde(default)]
4017 keep_conflicts: bool,
4018 #[serde(default)]
4021 autostash: bool,
4022 #[serde(default)]
4024 onto: Option<String>,
4025}
4026
4027impl RebaseRequest {
4028 fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4030 worktree_rebase::RebaseOptions {
4031 onto: self.onto.clone(),
4032 autostash: self.autostash,
4033 dry_run: false,
4036 keep_conflicts: self.keep_conflicts,
4037 git_bin: Some(git_bin),
4038 }
4039 }
4040}
4041
4042async fn plan_rebase(
4046 selection: &Selection,
4047 opts: &worktree_rebase::RebaseOptions,
4048) -> Result<worktree_rebase::Plan> {
4049 let selection = selection.clone();
4050 let opts = opts.clone();
4051 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4052 .await
4053 .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4054 .and_then(|inner| inner)
4055}
4056
4057fn rebase_reply(
4062 fetches: &[worktree_rebase::FetchOutcome],
4063 worktrees: &[worktree_rebase::WorktreeOutcome],
4064) -> Value {
4065 json!({ "fetches": fetches, "worktrees": worktrees })
4066}
4067
4068fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4072 let pending = plan
4073 .worktrees
4074 .iter()
4075 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4076 .count();
4077 let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4078 tracing::info!(
4079 requester = req.requester_key.as_deref().unwrap_or("-"),
4080 requested = req.paths.len(),
4081 pending,
4082 fetches = plan.fetches.len(),
4083 failed_fetches,
4084 "rebase check"
4085 );
4086}
4087
4088fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4092 use worktree_rebase::RebaseResult;
4093 let mut rebased = 0;
4094 let mut conflicts = 0;
4095 let mut left_in_place = 0;
4096 let mut skipped = 0;
4097 for outcome in outcomes {
4098 match &outcome.result {
4099 RebaseResult::Rebased { .. } => rebased += 1,
4100 RebaseResult::Conflict {
4101 left_in_place: k, ..
4102 } => {
4103 conflicts += 1;
4104 if *k {
4105 left_in_place += 1;
4106 }
4107 }
4108 RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4109 RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4110 }
4111 }
4112 tracing::info!(
4113 requester = req.requester_key.as_deref().unwrap_or("-"),
4114 requested = req.paths.len(),
4115 rebased,
4116 conflicts,
4117 left_in_place,
4118 skipped,
4119 "rebase execute"
4120 );
4121}
4122
4123#[derive(Debug, Clone, Deserialize)]
4135struct PushRequest {
4136 paths: Vec<PathBuf>,
4138 #[serde(default)]
4141 requester_key: Option<String>,
4142 #[serde(default)]
4144 check: bool,
4145 #[serde(default)]
4147 confirmed: bool,
4148}
4149
4150async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4154 let selection = selection.clone();
4155 tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4156 .await
4157 .map_err(|e| anyhow!("push planning task panicked: {e}"))
4158 .and_then(|inner| inner)
4159}
4160
4161fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4169 json!({ "worktrees": worktrees })
4170}
4171
4172fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4176 use worktree_push::PushResult;
4177 let pending = plan
4178 .worktrees
4179 .iter()
4180 .filter(|w| w.result.is_pending())
4181 .count();
4182 let forced = plan
4183 .worktrees
4184 .iter()
4185 .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4186 .count();
4187 let skipped = plan
4188 .worktrees
4189 .iter()
4190 .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4191 .count();
4192 tracing::info!(
4193 requester = req.requester_key.as_deref().unwrap_or("-"),
4194 requested = req.paths.len(),
4195 pending,
4196 forced,
4197 skipped,
4198 "push check"
4199 );
4200}
4201
4202fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4207 use worktree_push::PushResult;
4208 let mut pushed = 0;
4209 let mut forced = 0;
4210 let mut created = 0;
4211 let mut rejected = 0;
4212 let mut stale_rejected = 0;
4213 for outcome in outcomes {
4214 match &outcome.result {
4215 PushResult::Pushed { forced: f } => {
4216 pushed += 1;
4217 if *f {
4218 forced += 1;
4219 }
4220 }
4221 PushResult::Created => created += 1,
4222 PushResult::Rejected { stale, .. } => {
4223 rejected += 1;
4224 if *stale {
4225 stale_rejected += 1;
4226 }
4227 }
4228 PushResult::UpToDate
4229 | PushResult::WouldFastForward { .. }
4230 | PushResult::WouldForce { .. }
4231 | PushResult::WouldCreate
4232 | PushResult::Skipped { .. } => {}
4233 }
4234 }
4235 tracing::info!(
4236 requester = req.requester_key.as_deref().unwrap_or("-"),
4237 requested = req.paths.len(),
4238 pushed,
4239 forced,
4240 created,
4241 rejected,
4242 stale_rejected,
4243 "push execute"
4244 );
4245}
4246
4247#[derive(Debug, Clone, Deserialize)]
4253struct MergeQueueRequest {
4254 paths: Vec<PathBuf>,
4256 #[serde(default)]
4259 requester_key: Option<String>,
4260 #[serde(default)]
4262 check: bool,
4263 #[serde(default)]
4265 confirmed: bool,
4266}
4267
4268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4270struct PrRef {
4271 path: String,
4273 number: u64,
4275 url: String,
4277 branch: String,
4279}
4280
4281impl From<&Eligible> for PrRef {
4282 fn from(e: &Eligible) -> Self {
4283 Self {
4284 path: e.path.to_string_lossy().to_string(),
4285 number: e.number,
4286 url: e.url.clone(),
4287 branch: e.branch.clone(),
4288 }
4289 }
4290}
4291
4292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4295struct Skip {
4296 path: String,
4297 kind: String,
4298 detail: String,
4299}
4300
4301impl Skip {
4302 fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4303 Self {
4304 path: path.to_string_lossy().to_string(),
4305 kind: kind.to_string(),
4306 detail: detail.into(),
4307 }
4308 }
4309}
4310
4311#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4315struct EligibilityReport {
4316 eligible: Vec<PrRef>,
4317 skipped: Vec<Skip>,
4318}
4319
4320#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4322struct QueuedPr {
4323 path: String,
4324 number: u64,
4325 #[serde(skip_serializing_if = "is_false")]
4328 already_queued: bool,
4329}
4330
4331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4334struct EnqueueFailure {
4335 path: String,
4336 number: u64,
4337 error: String,
4338}
4339
4340#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4343struct EnqueueResult {
4344 queued: Vec<QueuedPr>,
4345 skipped: Vec<Skip>,
4346 failed: Vec<EnqueueFailure>,
4347}
4348
4349#[derive(Debug)]
4352struct LocalOk {
4353 path: PathBuf,
4354 target: PrTarget,
4355 head_sha: String,
4356}
4357
4358#[derive(Debug)]
4360struct Eligible {
4361 path: PathBuf,
4362 number: u64,
4363 url: String,
4364 branch: String,
4365 pr_id: String,
4367 already_queued: bool,
4369}
4370
4371fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4377 let Ok(repo) = Repository::discover(path) else {
4378 return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4379 };
4380 let (dirty, untracked) = count_dirty_untracked(&repo);
4382 if dirty > 0 {
4383 return Err(Skip::new(
4384 path,
4385 "dirty",
4386 format!("{dirty} modified tracked file(s) — commit or stash first"),
4387 ));
4388 }
4389 if untracked > 0 {
4390 return Err(Skip::new(
4391 path,
4392 "untracked",
4393 format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4394 ));
4395 }
4396 let Ok(head) = repo.head() else {
4399 return Err(Skip::new(
4400 path,
4401 "no-commits",
4402 "the branch has no commits yet",
4403 ));
4404 };
4405 let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4406 return Err(Skip::new(
4407 path,
4408 "no-commits",
4409 "HEAD does not resolve to a commit",
4410 ));
4411 };
4412 let Some(branch_name) = head
4415 .shorthand()
4416 .ok()
4417 .filter(|_| head.is_branch())
4418 .map(str::to_string)
4419 else {
4420 return Err(Skip::new(
4421 path,
4422 "detached",
4423 "HEAD is detached — no branch to enqueue",
4424 ));
4425 };
4426 let branch = git2::Branch::wrap(head);
4427 let Some(upstream_sha) = upstream_target(&branch) else {
4429 return Err(Skip::new(
4430 path,
4431 "no-upstream",
4432 "the branch tracks no upstream — push it first",
4433 ));
4434 };
4435 if upstream_sha != head_sha {
4436 return Err(Skip::new(
4437 path,
4438 "unpushed",
4439 "local commits are not on the remote yet — push first",
4440 ));
4441 }
4442 if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4445 if ahead > 0 {
4446 return Err(Skip::new(
4447 path,
4448 "unpushed",
4449 format!("{ahead} unpushed commit(s) — push first"),
4450 ));
4451 }
4452 }
4453 let Some(id) = remote_github_identity(&repo) else {
4456 return Err(Skip::new(
4457 path,
4458 "no-github",
4459 "the repository has no github.com remote",
4460 ));
4461 };
4462 Ok(LocalOk {
4463 path: path.to_path_buf(),
4464 target: PrTarget {
4465 owner: id.owner,
4466 name: id.name,
4467 branch: branch_name,
4468 },
4469 head_sha,
4470 })
4471}
4472
4473fn is_conflicting(state: Option<&str>) -> bool {
4478 matches!(state, Some("CONFLICTING" | "DIRTY"))
4479}
4480
4481fn check_label(state: PrCheckState) -> &'static str {
4483 match state {
4484 PrCheckState::Success => "passing",
4485 PrCheckState::Failure => "failing",
4486 PrCheckState::Pending => "still running",
4487 PrCheckState::None => "not reported",
4488 }
4489}
4490
4491fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4496 tracing::info!(
4497 requester = req.requester_key.as_deref().unwrap_or("-"),
4498 requested = req.paths.len(),
4499 eligible,
4500 skipped,
4501 "merge-queue check"
4502 );
4503}
4504
4505fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4509 tracing::info!(
4510 requester = req.requester_key.as_deref().unwrap_or("-"),
4511 queued,
4512 failed,
4513 skipped,
4514 "merge-queue enqueue"
4515 );
4516}
4517
4518fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4529 let mut skipped = Vec::new();
4530 let mut locals = Vec::new();
4531 for path in paths {
4532 match evaluate_local(path) {
4533 Ok(ok) => locals.push(ok),
4534 Err(skip) => skipped.push(skip),
4535 }
4536 }
4537 if locals.is_empty() {
4538 return Ok((Vec::new(), skipped));
4539 }
4540 let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4541 let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4542 let mut eligible = Vec::new();
4543 for local in locals {
4544 let Some(info) = resolved.get(&local.target) else {
4545 skipped.push(Skip::new(
4546 &local.path,
4547 "no-pr",
4548 "no open PR heads this branch",
4549 ));
4550 continue;
4551 };
4552 if info.head_oid != local.head_sha {
4553 skipped.push(Skip::new(
4554 &local.path,
4555 "stale",
4556 "the open PR's head differs from the local head — re-check",
4557 ));
4558 } else if info.is_draft {
4559 skipped.push(Skip::new(
4560 &local.path,
4561 "draft",
4562 format!("PR #{} is a draft", info.number),
4563 ));
4564 } else if is_conflicting(info.merge_state.as_deref()) {
4565 skipped.push(Skip::new(
4566 &local.path,
4567 "conflicting",
4568 format!("PR #{} has merge conflicts", info.number),
4569 ));
4570 } else if info.checks != PrCheckState::Success {
4571 skipped.push(Skip::new(
4572 &local.path,
4573 "checks-failing",
4574 format!(
4575 "PR #{} checks are {}",
4576 info.number,
4577 check_label(info.checks)
4578 ),
4579 ));
4580 } else {
4581 eligible.push(Eligible {
4582 path: local.path,
4583 number: info.number,
4584 url: info.url.clone(),
4585 branch: local.target.branch.clone(),
4586 pr_id: info.pr_id.clone(),
4587 already_queued: info.already_queued,
4588 });
4589 }
4590 }
4591 Ok((eligible, skipped))
4592}
4593
4594fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4599 let mut queued = Vec::new();
4600 let mut failed = Vec::new();
4601 for e in eligible {
4602 let path = e.path.to_string_lossy().to_string();
4603 if e.already_queued {
4604 queued.push(QueuedPr {
4605 path,
4606 number: e.number,
4607 already_queued: true,
4608 });
4609 continue;
4610 }
4611 match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4612 Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4613 path,
4614 number: e.number,
4615 already_queued: false,
4616 }),
4617 Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4618 path,
4619 number: e.number,
4620 error: msg,
4621 }),
4622 Err(err) => failed.push(EnqueueFailure {
4623 path,
4624 number: e.number,
4625 error: format!("{err:#}"),
4626 }),
4627 }
4628 }
4629 (queued, failed)
4630}
4631
4632fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4636 let target = canonical(path);
4637 entries
4638 .iter()
4639 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4640 .map(|e| (e.key.clone(), e.folders.len()))
4641 .collect()
4642}
4643
4644const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4651
4652const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4655
4656async fn await_windows_closed(
4663 registry: &WorktreesRegistry,
4664 path: &Path,
4665 requester: Option<&str>,
4666 timeout: Duration,
4667 poll: Duration,
4668) -> Result<()> {
4669 let deadline = std::time::Instant::now() + timeout;
4670 loop {
4671 let entries = registry.list();
4675 let path = path.to_path_buf();
4676 let requester = requester.map(str::to_string);
4677 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4678 windows_with_path(&entries, &path)
4679 .into_iter()
4680 .map(|(k, _)| k)
4681 .filter(|k| requester.as_deref() != Some(k))
4682 .collect()
4683 })
4684 .await
4685 .unwrap_or_default();
4686
4687 if remaining.is_empty() {
4688 return Ok(());
4689 }
4690 if std::time::Instant::now() >= deadline {
4691 bail!("window(s) did not close in time: {}", remaining.join(", "));
4692 }
4693 tokio::time::sleep(poll).await;
4694 }
4695}
4696
4697fn git_safety(path: &Path) -> Result<GitSafety> {
4704 if !path.exists() {
4705 return Ok(GitSafety {
4706 is_main: false,
4707 removable: true,
4708 risks: vec![],
4709 info: vec![Note::new("already-removed", "worktree no longer exists")],
4710 });
4711 }
4712 let repo = Repository::open(path)
4713 .with_context(|| format!("not a git worktree: {}", path.display()))?;
4714 if !repo.is_worktree() {
4716 return Ok(GitSafety {
4717 is_main: true,
4718 removable: false,
4719 risks: vec![],
4720 info: vec![Note::new(
4721 "main-working-tree",
4722 "the repository's main working tree is never deleted",
4723 )],
4724 });
4725 }
4726
4727 let mut risks = Vec::new();
4728 let mut info = Vec::new();
4729
4730 let (dirty, untracked) = count_dirty_untracked(&repo);
4731 if dirty > 0 {
4732 risks.push(Note::new(
4733 "dirty",
4734 format!("{dirty} modified tracked file(s) would be lost"),
4735 ));
4736 }
4737 if untracked > 0 {
4738 risks.push(Note::new(
4739 "untracked",
4740 format!("{untracked} untracked file(s) would be lost"),
4741 ));
4742 }
4743
4744 let state = repo.state();
4746 if state != RepositoryState::Clean {
4747 risks.push(Note::new(
4748 "in-progress",
4749 format!("an in-progress {state:?} operation would be lost"),
4750 ));
4751 }
4752
4753 if repo.head_detached().unwrap_or(false) {
4757 let lost = unreachable_commit_count(&repo).unwrap_or(0);
4758 if lost > 0 {
4759 risks.push(Note::new(
4760 "unreachable-commits",
4761 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4762 ));
4763 }
4764 }
4765
4766 if let Some(ahead) = current_branch_ahead(&repo) {
4769 if ahead > 0 {
4770 info.push(Note::new(
4771 "unpushed",
4772 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4773 ));
4774 }
4775 }
4776
4777 Ok(GitSafety {
4778 is_main: false,
4779 removable: true,
4780 risks,
4781 info,
4782 })
4783}
4784
4785fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4792 let mut opts = StatusOptions::new();
4793 opts.include_untracked(true)
4794 .recurse_untracked_dirs(true)
4795 .include_ignored(false)
4796 .exclude_submodules(true);
4797 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4798 return (0, 0);
4799 };
4800 let tracked = Status::INDEX_NEW
4803 | Status::INDEX_MODIFIED
4804 | Status::INDEX_DELETED
4805 | Status::INDEX_RENAMED
4806 | Status::INDEX_TYPECHANGE
4807 | Status::WT_MODIFIED
4808 | Status::WT_DELETED
4809 | Status::WT_TYPECHANGE
4810 | Status::WT_RENAMED
4811 | Status::CONFLICTED;
4812 let mut dirty = 0;
4813 let mut untracked = 0;
4814 for entry in statuses.iter() {
4815 let s = entry.status();
4816 if s.contains(Status::WT_NEW) {
4817 untracked += 1;
4818 }
4819 if s.intersects(tracked) {
4820 dirty += 1;
4821 }
4822 }
4823 (dirty, untracked)
4824}
4825
4826fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4833 let head_oid = repo.head().ok()?.target()?;
4834 let mut walk = repo.revwalk().ok()?;
4835 walk.push(head_oid).ok()?;
4836 for reference in repo.references().ok()? {
4837 let Ok(reference) = reference else { continue };
4838 if matches!(reference.name(), Ok("HEAD")) {
4841 continue;
4842 }
4843 if let Some(oid) = reference.target() {
4844 let _ = walk.hide(oid);
4845 }
4846 }
4847 Some(walk.flatten().count())
4848}
4849
4850fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4854 let head = repo.head().ok()?;
4855 if !head.is_branch() {
4856 return None;
4857 }
4858 let branch = git2::Branch::wrap(head);
4859 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4860}
4861
4862fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4868 let names = main_repo.worktrees()?;
4869 names
4870 .iter()
4871 .flatten() .flatten() .find(|name| {
4874 main_repo
4875 .find_worktree(name)
4876 .is_ok_and(|wt| canonical(wt.path()) == target)
4877 })
4878 .map(str::to_string)
4879 .ok_or_else(|| {
4880 anyhow!(
4881 "worktree {} is not registered in {}",
4882 target.display(),
4883 main_repo.path().display()
4884 )
4885 })
4886}
4887
4888const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4897 Duration::from_millis(250),
4898 Duration::from_millis(500),
4899 Duration::from_secs(1),
4900 Duration::from_secs(1),
4901];
4902
4903fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4909 matches!(
4910 e.raw_os_error(),
4911 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4912 )
4913}
4914
4915fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4921 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4922}
4923
4924fn remove_dir_all_retrying_with(
4930 dir: &Path,
4931 backoff: &[Duration],
4932 mut remove: impl FnMut() -> std::io::Result<()>,
4933) -> Result<()> {
4934 let mut backoff = backoff.iter();
4935 loop {
4936 match remove() {
4937 Ok(()) => return Ok(()),
4938 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4939 Err(e) => {
4940 if is_transient_rmdir_error(&e) {
4941 if let Some(delay) = backoff.next() {
4942 std::thread::sleep(*delay);
4943 continue;
4944 }
4945 }
4946 return Err(e).with_context(|| {
4947 format!("failed to remove worktree directory {}", dir.display())
4948 });
4949 }
4950 }
4951 }
4952}
4953
4954fn is_orphaned_worktree(path: &Path) -> bool {
4963 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4966 return false;
4967 };
4968 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4969 return false;
4970 };
4971 let admin = Path::new(admin);
4972 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4974}
4975
4976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4981enum Removal {
4982 Pruned,
4984 AlreadyGone,
4986}
4987
4988fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
5013 if !path.exists() {
5014 return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
5015 }
5016 let repo = match Repository::open(path) {
5017 Ok(repo) => repo,
5018 Err(_) if is_orphaned_worktree(path) => {
5021 remove_dir_all_retrying(path)?;
5022 return Ok(Removal::Pruned);
5023 }
5024 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
5025 };
5026 if !repo.is_worktree() {
5027 bail!(
5028 "refusing to delete the main working tree: {}",
5029 path.display()
5030 );
5031 }
5032 let commondir = canonical(repo.commondir());
5035 let main_root = commondir
5036 .parent()
5037 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5038 .to_path_buf();
5039 drop(repo);
5041 let main_repo = Repository::open(&main_root)
5042 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5043 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5044 let worktree = main_repo.find_worktree(&name)?;
5045
5046 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5048 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5049 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5050 }
5051
5052 remove_dir_all_retrying(path)?;
5055
5056 let mut opts = git2::WorktreePruneOptions::new();
5061 opts.valid(true).working_tree(false);
5062 worktree
5063 .prune(Some(&mut opts))
5064 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5065 Ok(Removal::Pruned)
5066}
5067
5068fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5084 let mut roots: Vec<PathBuf> = Vec::new();
5085 let mut push = |root: PathBuf| {
5086 if !roots.contains(&root) {
5087 roots.push(root);
5088 }
5089 };
5090 for ancestor in path.ancestors().skip(1) {
5092 if let Ok(repo) = Repository::open(ancestor) {
5093 if !repo.is_worktree() {
5094 if let Some(root) = canonical(repo.commondir()).parent() {
5095 push(root.to_path_buf());
5096 }
5097 }
5098 }
5099 }
5100 for folder in windows.iter().flat_map(|w| &w.folders) {
5101 if let Ok(repo) = Repository::discover(folder) {
5102 if let Some(root) = canonical(repo.commondir()).parent() {
5103 push(root.to_path_buf());
5104 }
5105 }
5106 }
5107 roots
5108}
5109
5110fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5119 let target = canonical(path);
5120 for root in candidate_main_repos {
5121 let Ok(main_repo) = Repository::open(root) else {
5122 continue;
5123 };
5124 if main_repo.is_worktree() {
5126 continue;
5127 }
5128 let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5130 continue;
5131 };
5132 let worktree = main_repo.find_worktree(&name)?;
5133 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5134 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5135 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5136 }
5137 let mut opts = git2::WorktreePruneOptions::new();
5138 opts.valid(true).working_tree(false);
5139 worktree.prune(Some(&mut opts)).with_context(|| {
5140 format!(
5141 "failed to prune orphaned worktree metadata for {}",
5142 path.display()
5143 )
5144 })?;
5145 return Ok(Removal::Pruned);
5146 }
5147 Ok(Removal::AlreadyGone)
5148}
5149
5150#[cfg(test)]
5151#[allow(clippy::unwrap_used, clippy::expect_used)]
5152mod tests {
5153 use super::*;
5154 use crate::test_support::shim::{
5155 retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5156 };
5157 use std::sync::MutexGuard;
5158
5159 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5160 json!({
5161 "key": key,
5162 "folders": [folder],
5163 "repo": repo,
5164 "title": format!("{key}-title"),
5165 "pid": 1234,
5166 })
5167 }
5168
5169 fn windows_of(payload: &Value) -> &Vec<Value> {
5171 payload
5172 .get("windows")
5173 .and_then(Value::as_array)
5174 .expect("windows array")
5175 }
5176
5177 #[tokio::test]
5178 async fn name_and_unknown_op() {
5179 let svc = WorktreesService::new();
5180 assert_eq!(svc.name(), "worktrees");
5181 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5182 }
5183
5184 #[tokio::test]
5185 async fn handle_routes_ops_and_shapes_payloads() {
5186 let svc = WorktreesService::new();
5187 let payload = svc.handle("list", Value::Null).await.unwrap();
5189 assert_eq!(payload, json!({ "windows": [] }));
5190
5191 let reply = svc
5193 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5194 .await
5195 .unwrap();
5196 assert_eq!(reply, json!({ "ok": true }));
5197 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5198 assert_eq!(windows.len(), 1);
5199 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5200 assert!(windows[0].get("last_seen").is_some());
5201
5202 let known = svc
5204 .handle("heartbeat", json!({ "key": "w1" }))
5205 .await
5206 .unwrap();
5207 assert_eq!(known, json!({ "known": true }));
5208 let unknown = svc
5209 .handle("heartbeat", json!({ "key": "nope" }))
5210 .await
5211 .unwrap();
5212 assert_eq!(unknown, json!({ "known": false }));
5213
5214 let reloaded = svc
5216 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5217 .await
5218 .unwrap();
5219 assert_eq!(
5220 reloaded,
5221 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5222 );
5223 assert!(svc.registry.take_reload_pending("w1"));
5224
5225 let gone = svc
5227 .handle("unregister", json!({ "key": "w1" }))
5228 .await
5229 .unwrap();
5230 assert_eq!(gone, json!({ "removed": true }));
5231 let again = svc
5232 .handle("unregister", json!({ "key": "w1" }))
5233 .await
5234 .unwrap();
5235 assert_eq!(again, json!({ "removed": false }));
5236 }
5237
5238 #[derive(Clone)]
5250 struct StubBackend {
5251 trusted: bool,
5252 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5255 writes: Arc<Mutex<Vec<geometry::Frame>>>,
5257 }
5258
5259 impl StubBackend {
5260 fn new(trusted: bool) -> Self {
5263 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5264 title: title.to_string(),
5265 frame: geometry::Frame {
5266 x,
5267 y: 0.0,
5268 width,
5269 height: 600.0,
5270 },
5271 minimized: false,
5272 fullscreen: false,
5273 standard: true,
5274 focused: false,
5275 };
5276 Self {
5277 trusted,
5278 windows: Arc::new(Mutex::new(vec![
5279 window("plan.md — ref-tree", 0.0, 800.0),
5280 window("main.rs — other-tree", 900.0, 500.0),
5281 ])),
5282 writes: Arc::new(Mutex::new(Vec::new())),
5283 }
5284 }
5285
5286 fn writes(&self) -> Vec<geometry::Frame> {
5287 self.writes
5288 .lock()
5289 .unwrap_or_else(PoisonError::into_inner)
5290 .clone()
5291 }
5292
5293 fn frame_of(&self, index: usize) -> geometry::Frame {
5295 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5296 }
5297
5298 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5300 let clone = self.clone();
5301 move || clone
5302 }
5303 }
5304
5305 impl geometry::WindowBackend for StubBackend {
5306 fn trusted(&self) -> bool {
5307 self.trusted
5308 }
5309
5310 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5311 pids.iter()
5312 .filter(|p| **p == 11 || **p == 12)
5313 .map(|p| (*p, 900))
5314 .collect()
5315 }
5316
5317 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5318 if app_pid != 900 {
5319 return Ok(Vec::new());
5320 }
5321 Ok(self
5322 .windows
5323 .lock()
5324 .unwrap_or_else(PoisonError::into_inner)
5325 .clone())
5326 }
5327
5328 fn set_frame(
5329 &self,
5330 id: geometry::WindowId,
5331 frame: geometry::Frame,
5332 ) -> Result<geometry::Frame, String> {
5333 self.writes
5334 .lock()
5335 .unwrap_or_else(PoisonError::into_inner)
5336 .push(frame);
5337 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5338 let window = windows
5339 .get_mut(id.index)
5340 .ok_or_else(|| format!("no window at index {}", id.index))?;
5341 window.frame = frame;
5342 Ok(frame)
5343 }
5344 }
5345
5346 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5349 svc.registry.register(
5350 serde_json::from_value(json!({
5351 "key": key,
5352 "folders": [format!("/tmp/{key}")],
5353 "title": title,
5354 "pid": pid,
5355 }))
5356 .expect("valid register payload"),
5357 );
5358 }
5359
5360 #[tokio::test]
5361 async fn reposition_requires_a_resolvable_reference() {
5362 let svc = WorktreesService::new();
5363 assert!(svc.handle("reposition", json!({})).await.is_err());
5366 assert!(svc
5367 .handle("reposition", json!({ "reference_key": " " }))
5368 .await
5369 .is_err());
5370 assert!(svc
5371 .handle("reposition", json!({ "reference_key": "ghost" }))
5372 .await
5373 .is_err());
5374 }
5375
5376 #[tokio::test]
5377 async fn reposition_moves_targets_and_records_an_undo() {
5378 let svc = WorktreesService::new();
5379 register_window(&svc, "ref", "ref-tree", 11);
5380 register_window(&svc, "other", "other-tree", 12);
5381 let backend = StubBackend::new(true);
5382
5383 let reply = svc
5384 .reposition_with(
5385 serde_json::from_value(json!({
5386 "reference_key": "ref",
5387 "target_keys": ["other"],
5388 }))
5389 .unwrap(),
5390 backend.factory(),
5391 )
5392 .await
5393 .unwrap();
5394
5395 assert_eq!(reply["trusted"], json!(true));
5396 assert_eq!(reply["moved"], json!(1));
5397 assert_eq!(reply["skipped"], json!(0));
5398 assert_eq!(reply["undoable"], json!(true));
5399 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5400 assert_eq!(reply["results"][0]["key"], json!("other"));
5401 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5402 assert_eq!(backend.writes().len(), 1);
5404 assert_eq!(
5405 backend.writes()[0],
5406 backend.frame_of(0),
5407 "wrote the reference window's own frame"
5408 );
5409 assert_eq!(
5410 backend.frame_of(1),
5411 backend.frame_of(0),
5412 "the target now occupies the reference's frame"
5413 );
5414
5415 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5419 assert_eq!(undone["moved"], json!(1));
5420 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5421 assert!(undone.get("reference").is_none(), "undo has no reference");
5422 assert_eq!(
5423 backend.frame_of(1),
5424 geometry::Frame {
5425 x: 900.0,
5426 y: 0.0,
5427 width: 500.0,
5428 height: 600.0,
5429 },
5430 "restored to exactly the pre-move frame"
5431 );
5432
5433 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5434 assert_eq!(
5435 again,
5436 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5437 );
5438 }
5439
5440 #[tokio::test]
5441 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5442 let svc = WorktreesService::new();
5443 register_window(&svc, "ref", "ref-tree", 11);
5444 register_window(&svc, "other", "other-tree", 12);
5445 let backend = StubBackend::new(true);
5446
5447 let reply = svc
5448 .reposition_with(
5449 serde_json::from_value(json!({
5450 "reference_key": "ref",
5451 "target_keys": ["other"],
5452 "check": true,
5453 }))
5454 .unwrap(),
5455 backend.factory(),
5456 )
5457 .await
5458 .unwrap();
5459
5460 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5461 assert!(
5462 reply.get("undoable").is_none(),
5463 "a dry run leaves nothing to undo"
5464 );
5465 assert!(
5466 backend.writes().is_empty(),
5467 "a dry run must not touch a window"
5468 );
5469 }
5470
5471 #[tokio::test]
5472 async fn reposition_reports_a_missing_permission_as_data() {
5473 let svc = WorktreesService::new();
5474 register_window(&svc, "ref", "ref-tree", 11);
5475 register_window(&svc, "other", "other-tree", 12);
5476 let backend = StubBackend::new(false);
5477
5478 let reply = svc
5479 .reposition_with(
5480 serde_json::from_value(json!({
5481 "reference_key": "ref",
5482 "target_keys": ["other"],
5483 }))
5484 .unwrap(),
5485 backend.factory(),
5486 )
5487 .await
5488 .unwrap();
5489
5490 assert_eq!(reply["trusted"], json!(false));
5493 assert_eq!(reply["moved"], json!(0));
5494 assert!(backend.writes().is_empty());
5495 }
5496
5497 #[tokio::test]
5498 async fn a_stale_target_key_is_skipped_not_fatal() {
5499 let svc = WorktreesService::new();
5500 register_window(&svc, "ref", "ref-tree", 11);
5501 register_window(&svc, "other", "other-tree", 12);
5502 let backend = StubBackend::new(true);
5503
5504 let reply = svc
5505 .reposition_with(
5506 serde_json::from_value(json!({
5507 "reference_key": "ref",
5508 "target_keys": ["closed-since", "ref", "other"],
5511 }))
5512 .unwrap(),
5513 backend.factory(),
5514 )
5515 .await
5516 .unwrap();
5517
5518 let outcomes: Vec<&str> = reply["results"]
5519 .as_array()
5520 .unwrap()
5521 .iter()
5522 .map(|r| r["outcome"].as_str().unwrap())
5523 .collect();
5524 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5525 assert_eq!(reply["moved"], json!(1));
5526 assert_eq!(reply["skipped"], json!(2));
5527 }
5528
5529 #[tokio::test]
5530 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5531 let svc = WorktreesService::new();
5532 register_window(&svc, "ref", "twin", 11);
5535 register_window(&svc, "other", "other-tree", 12);
5536 let backend = StubBackend::new(true);
5539 {
5540 let mut windows = backend
5541 .windows
5542 .lock()
5543 .unwrap_or_else(PoisonError::into_inner);
5544 windows[0].title = "a.rs — twin".to_string();
5545 windows[1].title = "b.rs — twin".to_string();
5546 }
5547
5548 let reply = svc
5549 .reposition_with(
5550 serde_json::from_value(json!({
5551 "reference_key": "ref",
5552 "target_keys": ["other"],
5553 }))
5554 .unwrap(),
5555 backend.factory(),
5556 )
5557 .await
5558 .unwrap();
5559
5560 assert_eq!(reply["trusted"], json!(true));
5561 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5562 assert!(
5563 reply["blocked"]["detail"]
5564 .as_str()
5565 .is_some_and(|d| d.contains("twin")),
5566 "the reason should name the ambiguous title: {reply}"
5567 );
5568 assert_eq!(reply["results"], json!([]), "no target is attempted");
5569 assert!(reply.get("undoable").is_none());
5570 assert!(backend.writes().is_empty());
5571
5572 let undone = svc
5574 .reposition_undo_with(StubBackend::new(true).factory())
5575 .await
5576 .unwrap();
5577 assert_eq!(undone["moved"], json!(0));
5578 }
5579
5580 #[tokio::test]
5581 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5582 let svc = WorktreesService::new();
5583 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5584 assert_eq!(reply["moved"], json!(0));
5585 assert_eq!(reply["results"], json!([]));
5586 }
5587
5588 #[test]
5589 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5590 let empty = geometry::RepositionReport {
5591 trusted: true,
5592 blocked: None,
5593 reference: None,
5594 results: Vec::new(),
5595 undo: Vec::new(),
5596 };
5597 assert_eq!(outcome_kinds(&empty), "-");
5598 }
5599
5600 #[tokio::test]
5601 async fn handle_rejects_missing_or_empty_key() {
5602 let svc = WorktreesService::new();
5603 assert!(svc.handle("register", json!({})).await.is_err());
5605 assert!(svc
5606 .handle("register", json!({ "key": " " }))
5607 .await
5608 .is_err());
5609 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5611 assert!(svc.handle("unregister", json!({})).await.is_err());
5612 }
5613
5614 #[test]
5615 fn display_name_prefers_repo_then_folder_basename() {
5616 let base = WindowEntry {
5617 key: "k".to_string(),
5618 folders: vec![PathBuf::from("/home/me/project")],
5619 repo: Some("my-repo".to_string()),
5620 title: None,
5621 pid: None,
5622 last_seen: Utc::now(),
5623 };
5624 assert_eq!(display_name(&base), "my-repo");
5625
5626 let no_repo = WindowEntry {
5627 repo: None,
5628 ..base.clone()
5629 };
5630 assert_eq!(display_name(&no_repo), "project");
5631
5632 let nothing = WindowEntry {
5633 repo: None,
5634 folders: vec![],
5635 ..base.clone()
5636 };
5637 assert_eq!(display_name(¬hing), "(no folder)");
5638
5639 let rootish = WindowEntry {
5642 repo: None,
5643 folders: vec![PathBuf::from("/")],
5644 ..base
5645 };
5646 assert_eq!(display_name(&rootish), "/");
5647 }
5648
5649 #[test]
5650 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5651 let now = Utc::now();
5652 let entries = vec![
5653 WindowEntry {
5658 key: "k2".to_string(),
5659 folders: vec![],
5660 repo: Some("solo".to_string()),
5661 title: Some("solo".to_string()),
5662 pid: None,
5663 last_seen: now,
5664 },
5665 WindowEntry {
5668 key: "k1".to_string(),
5669 folders: vec![PathBuf::from("/tmp/a")],
5670 repo: Some("repo".to_string()),
5671 title: Some("a branch".to_string()),
5672 pid: None,
5673 last_seen: now,
5674 },
5675 ];
5676 let items = window_menu_items(&entries);
5677 assert_eq!(items.len(), 2);
5679 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5680
5681 let action = items
5684 .iter()
5685 .find_map(|i| match i {
5686 MenuItem::Action(a) => Some(a),
5687 _ => None,
5688 })
5689 .expect("a focus action");
5690 assert_eq!(action.id, "focus:k1");
5691 assert_eq!(action.label, "repo · a branch");
5692
5693 let labels: Vec<&str> = items
5695 .iter()
5696 .filter_map(|i| match i {
5697 MenuItem::Label(t) => Some(t.as_str()),
5698 _ => None,
5699 })
5700 .collect();
5701 assert_eq!(labels, vec!["solo"]);
5702 }
5703
5704 #[tokio::test]
5705 async fn menu_and_status_shapes() {
5706 let svc = WorktreesService::new();
5707 let menu = svc.menu();
5709 assert_eq!(menu.title, "Worktrees");
5710 assert!(matches!(
5711 menu.items.first(),
5712 Some(MenuItem::Label(text)) if text == "No open windows"
5713 ));
5714 let status = svc.status().await;
5715 assert_eq!(status.name, "worktrees");
5716 assert!(status.healthy);
5717 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5718
5719 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5722 .await
5723 .unwrap();
5724 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5725 .await
5726 .unwrap();
5727 svc.handle(
5728 "register",
5729 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5730 )
5731 .await
5732 .unwrap();
5733 let status = svc.status().await;
5734 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5735
5736 let menu = svc.menu();
5737 assert_eq!(menu.items.len(), 3);
5739 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5740 let action_ids: Vec<&str> = menu
5741 .items
5742 .iter()
5743 .filter_map(|i| match i {
5744 MenuItem::Action(a) => Some(a.id.as_str()),
5745 _ => None,
5746 })
5747 .collect();
5748 assert!(action_ids.contains(&"focus:w1"));
5751 assert!(action_ids.contains(&"focus:w2"));
5752 assert!(!action_ids.contains(&"focus:w3"));
5753 }
5754
5755 #[test]
5756 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5757 let svc = WorktreesService::new();
5760 svc.start_menu_refresh();
5761 assert!(svc.refresh.lock().unwrap().is_none());
5762 }
5763
5764 #[tokio::test]
5765 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5766 let svc = WorktreesService::new();
5767 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5768 .await
5769 .unwrap();
5770 assert!(svc.menu_cache.lock().unwrap().is_none());
5772
5773 svc.start_menu_refresh();
5774 svc.start_menu_refresh();
5776
5777 let mut filled = false;
5779 for _ in 0..100 {
5780 if svc.menu_cache.lock().unwrap().is_some() {
5781 filled = true;
5782 break;
5783 }
5784 tokio::time::sleep(Duration::from_millis(10)).await;
5785 }
5786 assert!(filled, "background refresh should populate the menu cache");
5787
5788 let menu = svc.menu();
5790 assert_eq!(menu.title, "Worktrees");
5791 assert!(menu
5792 .items
5793 .iter()
5794 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5795
5796 svc.shutdown().await;
5798 assert!(svc.refresh.lock().unwrap().is_none());
5799 }
5800
5801 #[tokio::test]
5802 async fn default_constructs_an_empty_service() {
5803 let svc = WorktreesService::default();
5804 let payload = svc.handle("list", Value::Null).await.unwrap();
5805 assert_eq!(payload, json!({ "windows": [] }));
5806 }
5807
5808 #[tokio::test]
5811 async fn subscribe_streams_only_for_the_subscribe_op() {
5812 let svc = WorktreesService::new();
5813 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5817 assert!(svc.subscribe("list", &Value::Null).is_none());
5818 assert!(svc.subscribe("register", &Value::Null).is_none());
5819 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5820 }
5821
5822 #[tokio::test]
5823 async fn subscribe_snapshot_matches_the_tree_op() {
5824 let dir = tempfile::tempdir().unwrap();
5825 let repo = init_repo(dir.path());
5826 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5827 repo.set_head("refs/heads/main").unwrap();
5828
5829 let svc = WorktreesService::new();
5830 let stream = svc
5831 .subscribe("subscribe", &Value::Null)
5832 .expect("subscribe stream");
5833 assert_eq!(
5836 stream.snapshot().await,
5837 json!({ "repos": [], "show_closed": true })
5838 );
5839
5840 svc.handle(
5843 "register",
5844 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5845 )
5846 .await
5847 .unwrap();
5848 let snap = stream.snapshot().await;
5849 let tree = svc.handle("tree", Value::Null).await.unwrap();
5850 assert_eq!(snap, tree);
5851 let repos = snap["repos"].as_array().expect("repos array");
5852 assert_eq!(repos.len(), 1);
5853 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5854 }
5855
5856 #[tokio::test]
5857 async fn subscribe_changed_wakes_on_register() {
5858 let svc = WorktreesService::new();
5859 let mut stream = svc
5860 .subscribe("subscribe", &Value::Null)
5861 .expect("subscribe stream");
5862 tokio::select! {
5864 () = stream.changed() => panic!("changed resolved with no registry change"),
5865 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5866 }
5867 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5869 .await
5870 .unwrap();
5871 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5872 .await
5873 .expect("changed should resolve after a register");
5874 }
5875
5876 #[tokio::test]
5879 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5880 let reg = Arc::new(WorktreesRegistry::new());
5881 let cache = TreeSnapshotCache::with_ttl(
5883 reg,
5884 Arc::new(PrStatusCache::new()),
5885 Duration::from_secs(60),
5886 );
5887 let first = cache.snapshot().await;
5889 assert_eq!(cache.compute_count(), 1);
5890 let second = cache.snapshot().await;
5893 assert_eq!(
5894 cache.compute_count(),
5895 1,
5896 "an unchanged read must not rebuild"
5897 );
5898 assert_eq!(first, second);
5899 }
5900
5901 #[tokio::test]
5902 async fn tree_cache_single_flights_a_read_burst() {
5903 let reg = Arc::new(WorktreesRegistry::new());
5904 let cache = Arc::new(TreeSnapshotCache::with_ttl(
5905 reg,
5906 Arc::new(PrStatusCache::new()),
5907 Duration::from_secs(60),
5908 ));
5909 let mut handles = Vec::new();
5913 for _ in 0..16 {
5914 let cache = cache.clone();
5915 handles.push(tokio::spawn(async move { cache.snapshot().await }));
5916 }
5917 let mut results = Vec::new();
5918 for handle in handles {
5919 results.push(handle.await.unwrap());
5920 }
5921 assert_eq!(
5922 cache.compute_count(),
5923 1,
5924 "a concurrent read burst must build the tree once"
5925 );
5926 assert!(
5927 results.windows(2).all(|w| w[0] == w[1]),
5928 "every reader must observe the identical snapshot"
5929 );
5930 }
5931
5932 #[tokio::test]
5933 async fn tree_cache_rebuilds_on_registry_change() {
5934 let reg = Arc::new(WorktreesRegistry::new());
5935 let cache = TreeSnapshotCache::with_ttl(
5936 reg.clone(),
5937 Arc::new(PrStatusCache::new()),
5938 Duration::from_secs(60),
5939 );
5940 cache.snapshot().await;
5941 assert_eq!(cache.compute_count(), 1);
5942 assert!(reg.set_show_closed(false));
5946 cache.snapshot().await;
5947 assert_eq!(
5948 cache.compute_count(),
5949 2,
5950 "a generation bump must force a rebuild"
5951 );
5952 }
5953
5954 #[tokio::test]
5955 async fn tree_cache_rebuilds_after_ttl_expiry() {
5956 let reg = Arc::new(WorktreesRegistry::new());
5957 let cache =
5960 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5961 cache.snapshot().await;
5962 cache.snapshot().await;
5963 assert_eq!(
5964 cache.compute_count(),
5965 2,
5966 "an expired TTL must force a rebuild each read"
5967 );
5968 }
5969
5970 #[tokio::test]
5971 async fn subscribe_streams_share_one_build_per_generation() {
5972 let svc = WorktreesService::new();
5973 let s1 = svc
5974 .subscribe("subscribe", &Value::Null)
5975 .expect("subscribe stream");
5976 let s2 = svc
5977 .subscribe("subscribe", &Value::Null)
5978 .expect("subscribe stream");
5979 let a = s1.snapshot().await;
5982 let b = s2.snapshot().await;
5983 assert_eq!(a, b);
5984 assert_eq!(
5985 svc.tree_cache.compute_count(),
5986 1,
5987 "N streams on one generation must share a single build"
5988 );
5989 }
5990
5991 #[tokio::test]
5994 async fn set_show_closed_toggles_the_snapshot_field() {
5995 let svc = WorktreesService::new();
5996 assert_eq!(
5998 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5999 json!(true)
6000 );
6001 let reply = svc
6003 .handle("set-show-closed", json!({ "show_closed": false }))
6004 .await
6005 .unwrap();
6006 assert_eq!(reply, json!({ "ok": true }));
6007 assert_eq!(
6008 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6009 json!(false)
6010 );
6011 }
6012
6013 #[tokio::test]
6014 async fn set_show_closed_rejects_a_non_boolean_payload() {
6015 let svc = WorktreesService::new();
6016 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
6017 assert!(svc
6018 .handle("set-show-closed", json!({ "show_closed": "yes" }))
6019 .await
6020 .is_err());
6021 }
6022
6023 #[tokio::test]
6024 async fn set_show_closed_wakes_the_subscription() {
6025 let svc = WorktreesService::new();
6026 let mut stream = svc
6027 .subscribe("subscribe", &Value::Null)
6028 .expect("subscribe stream");
6029 svc.handle("set-show-closed", json!({ "show_closed": false }))
6031 .await
6032 .unwrap();
6033 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6034 .await
6035 .expect("changed should resolve after a toggle flip");
6036 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6038 }
6039
6040 #[tokio::test]
6041 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6042 let dir = tempfile::tempdir().unwrap();
6045 github_repo(dir.path());
6046 let svc = WorktreesService::new();
6047 svc.handle(
6048 "register",
6049 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6050 )
6051 .await
6052 .unwrap();
6053
6054 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6055 assert!(
6056 repo.get("polling_enabled").is_none(),
6057 "default off omits the flag: {repo:?}"
6058 );
6059
6060 let reply = svc
6061 .handle(
6062 "set-polling",
6063 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6064 )
6065 .await
6066 .unwrap();
6067 assert_eq!(reply, json!({ "ok": true }));
6068 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6069 assert_eq!(repo["polling_enabled"], json!(true));
6070
6071 svc.handle(
6072 "set-polling",
6073 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6074 )
6075 .await
6076 .unwrap();
6077 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6078 assert!(repo.get("polling_enabled").is_none());
6079 }
6080
6081 #[tokio::test]
6082 async fn set_polling_rejects_missing_or_empty_fields() {
6083 let svc = WorktreesService::new();
6084 assert!(svc
6086 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6087 .await
6088 .is_err());
6089 assert!(svc
6091 .handle("set-polling", json!({ "enabled": true }))
6092 .await
6093 .is_err());
6094 assert!(svc
6096 .handle(
6097 "set-polling",
6098 json!({ "owner": " ", "name": "n", "enabled": true })
6099 )
6100 .await
6101 .is_err());
6102 }
6103
6104 #[tokio::test]
6105 async fn set_polling_wakes_the_subscription() {
6106 let dir = tempfile::tempdir().unwrap();
6107 github_repo(dir.path());
6108 let svc = WorktreesService::new();
6109 svc.handle(
6110 "register",
6111 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6112 )
6113 .await
6114 .unwrap();
6115 let mut stream = svc
6116 .subscribe("subscribe", &Value::Null)
6117 .expect("subscribe stream");
6118 svc.handle(
6119 "set-polling",
6120 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6121 )
6122 .await
6123 .unwrap();
6124 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6125 .await
6126 .expect("changed should resolve after enabling a repo");
6127 let repo = repos_of(&stream.snapshot().await)[0].clone();
6128 assert_eq!(repo["polling_enabled"], json!(true));
6129 }
6130
6131 #[tokio::test]
6132 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6133 let dir = tempfile::tempdir().unwrap();
6137 let repo = github_repo(dir.path());
6138 let head = repo.head().unwrap().target().unwrap().to_string();
6139 let svc = WorktreesService::new();
6140 svc.handle(
6141 "register",
6142 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6143 )
6144 .await
6145 .unwrap();
6146 svc.registry.set_polling("rust-works", "omni-dev", true);
6147
6148 let mut badges = HashMap::new();
6149 badges.insert(
6150 PrTarget {
6151 owner: "rust-works".into(),
6152 name: "omni-dev".into(),
6153 branch: "main".into(),
6154 },
6155 pr(pending_badge(7, &head)),
6156 );
6157 svc.pr_cache.replace(badges);
6158
6159 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6160 assert_eq!(wt["pr"]["number"], json!(7));
6161
6162 svc.handle(
6163 "set-polling",
6164 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6165 )
6166 .await
6167 .unwrap();
6168 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6169 assert!(
6170 wt.get("pr").is_none(),
6171 "a disabled repo must carry no badge: {wt:?}"
6172 );
6173 }
6174
6175 #[tokio::test]
6176 async fn an_expired_lease_drops_the_flag_and_badges() {
6177 let dir = tempfile::tempdir().unwrap();
6181 let repo = github_repo(dir.path());
6182 let head = repo.head().unwrap().target().unwrap().to_string();
6183 let svc = WorktreesService::new();
6184 svc.handle(
6185 "register",
6186 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6187 )
6188 .await
6189 .unwrap();
6190 svc.registry.set_polling("rust-works", "omni-dev", true);
6191 let mut badges = HashMap::new();
6192 badges.insert(
6193 PrTarget {
6194 owner: "rust-works".into(),
6195 name: "omni-dev".into(),
6196 branch: "main".into(),
6197 },
6198 pr(pending_badge(7, &head)),
6199 );
6200 svc.pr_cache.replace(badges);
6201
6202 let snap = svc.handle("tree", Value::Null).await.unwrap();
6204 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6205 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6206 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6207
6208 svc.registry.set_polling_expiry(
6210 "rust-works",
6211 "omni-dev",
6212 Utc::now() - chrono::Duration::minutes(1),
6213 );
6214
6215 let snap = svc.handle("tree", Value::Null).await.unwrap();
6216 let repo0 = &repos_of(&snap)[0];
6217 assert!(
6218 repo0.get("polling_enabled").is_none(),
6219 "expired lease drops the flag: {repo0:?}"
6220 );
6221 assert!(
6222 repo0["worktrees"][0].get("pr").is_none(),
6223 "expired lease drops the badge"
6224 );
6225 assert!(
6226 pr_targets_from_snapshot(&snap).is_empty(),
6227 "the poller no longer watches an expired repo"
6228 );
6229 }
6230
6231 #[tokio::test]
6232 async fn polling_prefs_persist_across_reloads_with_0600() {
6233 let dir = tempfile::tempdir().unwrap();
6236 let prefs = dir.path().join("worktrees-polling.json");
6237
6238 let svc = WorktreesService::new();
6239 svc.load_polling_prefs(prefs.clone());
6240 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6241 svc.handle(
6242 "set-polling",
6243 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6244 )
6245 .await
6246 .unwrap();
6247 assert!(prefs.exists());
6248 #[cfg(unix)]
6249 {
6250 use std::os::unix::fs::PermissionsExt;
6251 assert_eq!(
6252 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6253 0o600
6254 );
6255 }
6256
6257 let svc2 = WorktreesService::new();
6259 svc2.load_polling_prefs(prefs.clone());
6260 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6261
6262 svc2.handle(
6264 "set-polling",
6265 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6266 )
6267 .await
6268 .unwrap();
6269 let svc3 = WorktreesService::new();
6270 svc3.load_polling_prefs(prefs);
6271 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6272 }
6273
6274 #[test]
6275 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6276 let dir = tempfile::tempdir().unwrap();
6281
6282 let corrupt = dir.path().join("worktrees-polling.json");
6284 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6285 let svc = WorktreesService::new();
6286 svc.load_polling_prefs(corrupt);
6287 assert!(svc.registry.enabled_polling_repos().is_empty());
6288
6289 let as_dir = dir.path().join("is-a-directory");
6291 std::fs::create_dir(&as_dir).unwrap();
6292 let svc2 = WorktreesService::new();
6293 svc2.load_polling_prefs(as_dir);
6294 assert!(svc2.registry.enabled_polling_repos().is_empty());
6295 }
6296
6297 #[tokio::test]
6298 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6299 let dir = tempfile::tempdir().unwrap();
6302 github_repo(dir.path());
6303 let bin_dir = tempfile::tempdir().unwrap();
6304 let marker = bin_dir.path().join("spawned");
6305 let fake = bin_dir.path().join("fake-gh");
6306 std::fs::write(
6307 &fake,
6308 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6309 )
6310 .unwrap();
6311 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6312 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6313 std::fs::set_permissions(&fake, perms).unwrap();
6314
6315 let svc = WorktreesService::new();
6316 svc.handle(
6317 "register",
6318 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6319 )
6320 .await
6321 .unwrap();
6322 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6324 tokio::time::sleep(Duration::from_millis(200)).await;
6325 svc.shutdown().await;
6326 assert!(
6327 !marker.exists(),
6328 "a registered-but-not-enabled repo must drive zero gh"
6329 );
6330 }
6331
6332 #[tokio::test]
6333 async fn menu_action_rejects_unknown_and_missing_window() {
6334 let svc = WorktreesService::new();
6335 assert!(svc.menu_action("bogus").await.is_err());
6336 assert!(svc.menu_action("focus:nope").await.is_err());
6338 svc.shutdown().await;
6339 }
6340
6341 struct VscodeBinGuard(Option<std::ffi::OsString>);
6348 impl Drop for VscodeBinGuard {
6349 fn drop(&mut self) {
6350 match self.0.take() {
6351 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6352 None => std::env::remove_var(VSCODE_BIN_ENV),
6353 }
6354 }
6355 }
6356
6357 #[tokio::test]
6358 async fn menu_action_focus_resolves_folder_and_spawns() {
6359 let dir = tempfile::tempdir().unwrap();
6360 let svc = WorktreesService::new();
6361 svc.handle(
6362 "register",
6363 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6364 )
6365 .await
6366 .unwrap();
6367
6368 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6371 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6372 svc.menu_action("focus:w1").await.unwrap();
6373 }
6374
6375 #[tokio::test]
6376 async fn open_rejects_missing_relative_or_nonexistent_path() {
6377 let svc = WorktreesService::new();
6378 assert!(svc.handle("open", json!({})).await.is_err());
6380 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6381 assert!(svc
6384 .handle("open", json!({ "path": "relative/dir" }))
6385 .await
6386 .is_err());
6387 assert!(svc
6388 .handle("open", json!({ "path": "-flag" }))
6389 .await
6390 .is_err());
6391 assert!(svc
6394 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6395 .await
6396 .is_err());
6397 svc.shutdown().await;
6398 }
6399
6400 #[tokio::test]
6401 async fn open_focuses_an_existing_absolute_dir() {
6402 let dir = tempfile::tempdir().unwrap();
6403 let svc = WorktreesService::new();
6404 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6409 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6410 let reply = svc
6411 .handle("open", json!({ "path": dir.path() }))
6412 .await
6413 .unwrap();
6414 assert_eq!(reply, json!({ "ok": true }));
6415 svc.shutdown().await;
6416 }
6417
6418 #[test]
6419 fn focus_window_with_validates_folder_then_spawns() {
6420 let dir = tempfile::tempdir().unwrap();
6421 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6423 assert!(
6424 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6425 );
6426 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6428 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6430 }
6431
6432 #[test]
6433 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6434 assert_eq!(
6436 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6437 PathBuf::from("/custom/code")
6438 );
6439 let existing = tempfile::NamedTempFile::new().unwrap();
6441 let existing_path = existing.path().to_str().unwrap();
6442 assert_eq!(
6443 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6444 PathBuf::from(existing_path)
6445 );
6446 assert_eq!(
6448 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6449 PathBuf::from("code")
6450 );
6451 let _ = resolve_code_binary();
6453 }
6454
6455 fn init_repo(dir: &Path) -> Repository {
6460 let repo = Repository::init(dir).unwrap();
6461 let mut cfg = repo.config().unwrap();
6462 cfg.set_str("user.name", "Test").unwrap();
6463 cfg.set_str("user.email", "test@example.com").unwrap();
6464 repo
6465 }
6466
6467 fn empty_commit(
6470 repo: &Repository,
6471 refname: Option<&str>,
6472 parents: &[&git2::Commit<'_>],
6473 msg: &str,
6474 ) -> git2::Oid {
6475 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6476 let tree = repo
6477 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6478 .unwrap();
6479 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6480 .unwrap()
6481 }
6482
6483 fn commit_file(
6488 repo: &Repository,
6489 refname: &str,
6490 name: &str,
6491 content: &[u8],
6492 msg: &str,
6493 ) -> git2::Oid {
6494 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6495 let blob = repo.blob(content).unwrap();
6496 let mut builder = repo.treebuilder(None).unwrap();
6497 builder.insert(name, blob, 0o100_644).unwrap();
6498 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6499 let parent = repo
6500 .refname_to_id(refname)
6501 .ok()
6502 .and_then(|oid| repo.find_commit(oid).ok());
6503 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6504 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6505 .unwrap()
6506 }
6507
6508 fn diverging_repo(dir: &Path) -> Repository {
6511 let repo = init_repo(dir);
6512 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6514 let a_commit = repo.find_commit(a).unwrap();
6515 let c = empty_commit(&repo, None, &[&a_commit], "C");
6517 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6518 .unwrap();
6519 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6521 drop(a_commit);
6523 repo.set_head("refs/heads/main").unwrap();
6524 let mut cfg = repo.config().unwrap();
6526 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6527 .unwrap();
6528 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6529 .unwrap();
6530 cfg.set_str("branch.main.remote", "origin").unwrap();
6531 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6532 repo
6533 }
6534
6535 fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6541 let repo = init_repo(dir);
6542 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6543 let a_commit = repo.find_commit(a).unwrap();
6544 let c = empty_commit(&repo, None, &[&a_commit], "C");
6545 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6546 .unwrap();
6547 drop(a_commit);
6548 repo.set_head("refs/heads/main").unwrap();
6549 repo
6550 }
6551
6552 #[test]
6553 fn git_status_reads_branch_and_ahead_behind() {
6554 let dir = tempfile::tempdir().unwrap();
6555 let _repo = diverging_repo(dir.path());
6556 let status = git_status(dir.path());
6557 assert_eq!(status.branch.as_deref(), Some("main"));
6558 assert_eq!(status.ahead, Some(1));
6559 assert_eq!(status.behind, Some(1));
6560 assert_eq!(
6562 status.main_repo.as_deref(),
6563 dir.path().file_name().and_then(|n| n.to_str())
6564 );
6565 assert!(!status.is_worktree);
6566 }
6567
6568 #[test]
6569 fn git_status_empty_repo_is_unborn() {
6570 let dir = tempfile::tempdir().unwrap();
6574 init_repo(dir.path());
6575 let status = git_status(dir.path());
6576 assert_eq!(status.branch, None);
6577 assert_eq!(status.head_sha, None);
6579 assert_eq!(status.ahead, None);
6580 assert_eq!(status.behind, None);
6581 assert_eq!(
6582 status.main_repo.as_deref(),
6583 dir.path().file_name().and_then(|n| n.to_str())
6584 );
6585 assert!(!status.is_worktree);
6586 }
6587
6588 #[test]
6589 fn git_status_no_upstream_reports_branch_only() {
6590 let dir = tempfile::tempdir().unwrap();
6591 let repo = init_repo(dir.path());
6592 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6593 repo.set_head("refs/heads/main").unwrap();
6594 let status = git_status(dir.path());
6595 assert_eq!(status.branch.as_deref(), Some("main"));
6596 assert_eq!(status.ahead, None);
6598 assert_eq!(status.behind, None);
6599 assert_eq!(status.upstream_sha, None);
6602 }
6603
6604 #[test]
6605 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6606 let plain = tempfile::tempdir().unwrap();
6608 assert_eq!(git_status(plain.path()), GitStatus::default());
6609
6610 let dir = tempfile::tempdir().unwrap();
6613 let repo = init_repo(dir.path());
6614 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6615 repo.set_head_detached(a).unwrap();
6616 let status = git_status(dir.path());
6617 assert_eq!(status.branch, None);
6618 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6621 assert_eq!(status.ahead, None);
6622 assert_eq!(status.behind, None);
6623 assert_eq!(status.upstream_sha, None);
6626 assert_eq!(
6627 status.main_repo.as_deref(),
6628 dir.path().file_name().and_then(|n| n.to_str())
6629 );
6630 assert!(!status.is_worktree);
6631 }
6632
6633 #[test]
6636 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6637 let dir = tempfile::tempdir().unwrap();
6641 let repo = diverging_repo(dir.path());
6642 let status = git_status_cheap(dir.path());
6643 assert_eq!(status.branch.as_deref(), Some("main"));
6644 assert_eq!(status.ahead, None);
6645 assert_eq!(status.behind, None);
6646 assert_eq!(
6647 status.main_repo.as_deref(),
6648 dir.path().file_name().and_then(|n| n.to_str())
6649 );
6650 let head = repo.head().unwrap().target().unwrap();
6653 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6654 }
6655
6656 #[test]
6659 fn git_status_head_sha_tracks_new_commits() {
6660 let dir = tempfile::tempdir().unwrap();
6666 let repo = init_repo(dir.path());
6667 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6668 repo.set_head("refs/heads/main").unwrap();
6669 let before = git_status_cheap(dir.path());
6670 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6671
6672 let head = repo.find_commit(a).unwrap();
6673 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6674 let after = git_status_cheap(dir.path());
6675 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6676 assert_ne!(before.head_sha, after.head_sha);
6677 assert_eq!(before.branch, after.branch);
6680 }
6681
6682 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6688 repo.reference("refs/remotes/origin/main", oid, true, "push")
6689 .unwrap();
6690 }
6691
6692 #[test]
6693 fn git_status_upstream_sha_tracks_a_push() {
6694 let dir = tempfile::tempdir().unwrap();
6701 let repo = diverging_repo(dir.path());
6702 let before = git_status(dir.path());
6703 assert_eq!(before.ahead, Some(1));
6704 assert_eq!(before.behind, Some(1));
6705
6706 let head = repo.head().unwrap().target().unwrap();
6707 simulate_push(&repo, head);
6708 let after = git_status(dir.path());
6709
6710 assert_eq!(
6712 after.upstream_sha.as_deref(),
6713 Some(head.to_string().as_str())
6714 );
6715 assert_ne!(before.upstream_sha, after.upstream_sha);
6716 assert_eq!(after.ahead, Some(0));
6717 assert_eq!(after.behind, Some(0));
6718 assert_eq!(before.branch, after.branch);
6722 assert_eq!(before.head_sha, after.head_sha);
6723 }
6724
6725 #[test]
6726 fn git_status_cheap_reports_upstream_sha() {
6727 let dir = tempfile::tempdir().unwrap();
6732 let repo = diverging_repo(dir.path());
6733 let status = git_status_cheap(dir.path());
6734 let upstream = repo
6735 .find_branch("origin/main", git2::BranchType::Remote)
6736 .unwrap()
6737 .get()
6738 .target()
6739 .unwrap();
6740 assert_eq!(
6741 status.upstream_sha.as_deref(),
6742 Some(upstream.to_string().as_str())
6743 );
6744 assert_eq!(status.ahead, None);
6745 assert_eq!(status.behind, None);
6746 }
6747
6748 #[test]
6749 fn folder_divergence_computes_ahead_behind_and_degrades() {
6750 let dir = tempfile::tempdir().unwrap();
6752 let _repo = diverging_repo(dir.path());
6753 assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6754
6755 let no_up = tempfile::tempdir().unwrap();
6757 let repo = init_repo(no_up.path());
6758 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6759 repo.set_head("refs/heads/main").unwrap();
6760 assert_eq!(folder_divergence(no_up.path()).ahead_behind, None);
6761
6762 let detached = tempfile::tempdir().unwrap();
6764 let drepo = init_repo(detached.path());
6765 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6766 drepo.set_head_detached(a).unwrap();
6767 assert_eq!(folder_divergence(detached.path()).ahead_behind, None);
6768 let plain = tempfile::tempdir().unwrap();
6769 assert_eq!(folder_divergence(plain.path()).ahead_behind, None);
6770 }
6771
6772 #[test]
6775 fn folder_divergence_computes_main_behind_and_degrades() {
6776 let dir = tempfile::tempdir().unwrap();
6780 let _repo = behind_main_no_upstream_repo(dir.path());
6781 assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
6782
6783 let detached = tempfile::tempdir().unwrap();
6785 let drepo = init_repo(detached.path());
6786 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6787 drepo.set_head_detached(a).unwrap();
6788 assert_eq!(folder_divergence(detached.path()).main_behind, None);
6789 let plain = tempfile::tempdir().unwrap();
6790 assert_eq!(folder_divergence(plain.path()).main_behind, None);
6791 }
6792
6793 #[test]
6794 fn folder_divergence_skips_main_behind_when_own_upstream_is_the_default_branch() {
6795 let dir = tempfile::tempdir().unwrap();
6800 let _repo = diverging_repo(dir.path());
6801 assert_eq!(folder_divergence(dir.path()).main_behind, None);
6802 }
6803
6804 #[test]
6805 fn folder_divergence_has_no_main_behind_without_a_resolvable_default_branch() {
6806 let dir = tempfile::tempdir().unwrap();
6808 let repo = init_repo(dir.path());
6809 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6810 repo.set_head("refs/heads/main").unwrap();
6811 assert_eq!(folder_divergence(dir.path()).main_behind, None);
6812 }
6813
6814 #[test]
6815 fn folder_divergence_reports_independent_main_behind_and_ahead_behind_counts() {
6816 let dir = tempfile::tempdir().unwrap();
6817 let repo = init_repo(dir.path());
6818 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6819 let base_commit = repo.find_commit(base).unwrap();
6820
6821 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6823 let m1_commit = repo.find_commit(m1).unwrap();
6824 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6825 let m2_commit = repo.find_commit(m2).unwrap();
6826 let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6827 repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6828 .unwrap();
6829
6830 let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6833 repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6834 .unwrap();
6835 empty_commit(
6836 &repo,
6837 Some("refs/heads/feature"),
6838 &[&base_commit],
6839 "local-feature",
6840 );
6841 drop(base_commit);
6842 drop(m1_commit);
6843 drop(m2_commit);
6844
6845 repo.set_head("refs/heads/feature").unwrap();
6846 let mut cfg = repo.config().unwrap();
6847 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6848 .unwrap();
6849 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6850 .unwrap();
6851 cfg.set_str("branch.feature.remote", "origin").unwrap();
6852 cfg.set_str("branch.feature.merge", "refs/heads/feature")
6853 .unwrap();
6854
6855 assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6856 assert_eq!(folder_divergence(dir.path()).main_behind, Some(3));
6857 }
6858
6859 #[tokio::test]
6860 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6861 let diverging = tempfile::tempdir().unwrap();
6862 let _d = diverging_repo(diverging.path());
6863 let no_up = tempfile::tempdir().unwrap();
6864 let repo = init_repo(no_up.path());
6865 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6866 repo.set_head("refs/heads/main").unwrap();
6867
6868 let svc = WorktreesService::new();
6869 let diverging_path = diverging.path().display().to_string();
6870 let no_up_path = no_up.path().display().to_string();
6871 let reply = svc
6872 .handle(
6873 "ahead-behind",
6874 json!({ "paths": [&diverging_path, &no_up_path] }),
6875 )
6876 .await
6877 .unwrap();
6878 let results = reply.get("results").unwrap();
6879 let d = results.get(diverging_path.as_str()).unwrap();
6883 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6884 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6885 assert!(d.get("main_behind").is_none(), "{d:?}");
6886 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6890
6891 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6893 assert_eq!(empty.get("results"), Some(&json!({})));
6894 }
6895
6896 #[tokio::test]
6900 async fn credit_accept_outage_reaches_the_registry_without_dropping_a_live_window() {
6901 let svc = WorktreesService::new();
6902 svc.handle(
6903 "register",
6904 json!({ "key": "w", "folders": [], "repo": "x" }),
6905 )
6906 .await
6907 .unwrap();
6908
6909 svc.credit_accept_outage(Duration::from_secs(5));
6910
6911 assert_eq!(svc.registry.list().len(), 1);
6912 }
6913
6914 #[tokio::test]
6918 async fn concurrent_ahead_behind_requests_all_get_the_same_correct_answer() {
6919 let dir = tempfile::tempdir().unwrap();
6920 let _repo = diverging_repo(dir.path());
6921 let svc = WorktreesService::new();
6922 let path = dir.path().display().to_string();
6923
6924 let replies = futures::future::join_all(
6925 (0..16).map(|_| svc.handle("ahead-behind", json!({ "paths": [&path, &path] }))),
6926 )
6927 .await;
6928
6929 for reply in replies {
6930 let reply = reply.unwrap();
6931 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6932 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
6933 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
6934 }
6935 }
6936
6937 #[tokio::test]
6938 async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6939 let dir = tempfile::tempdir().unwrap();
6940 let _repo = behind_main_no_upstream_repo(dir.path());
6941 let svc = WorktreesService::new();
6942 let path = dir.path().display().to_string();
6943 let reply = svc
6944 .handle("ahead-behind", json!({ "paths": [&path] }))
6945 .await
6946 .unwrap();
6947 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6948 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6949 assert!(entry.get("ahead").is_none(), "{entry:?}");
6951 assert!(entry.get("behind").is_none(), "{entry:?}");
6952 }
6953
6954 #[tokio::test]
6955 async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6956 let dir = tempfile::tempdir().unwrap();
6961 let repo = init_repo(dir.path());
6962 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6963 let base_commit = repo.find_commit(base).unwrap();
6964
6965 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6966 let m1_commit = repo.find_commit(m1).unwrap();
6967 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6968 repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6969 .unwrap();
6970
6971 let r = empty_commit(
6972 &repo,
6973 Some("refs/heads/release"),
6974 &[&base_commit],
6975 "release",
6976 );
6977 repo.reference("refs/remotes/origin/release", r, true, "origin release")
6978 .unwrap();
6979 drop(base_commit);
6980 drop(m1_commit);
6981
6982 repo.set_head("refs/heads/release").unwrap();
6983 let mut cfg = repo.config().unwrap();
6984 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6985 .unwrap();
6986 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6987 .unwrap();
6988 cfg.set_str("branch.release.remote", "origin").unwrap();
6989 cfg.set_str("branch.release.merge", "refs/heads/release")
6990 .unwrap();
6991
6992 let svc = WorktreesService::new();
6993 let path = dir.path().display().to_string();
6994 let reply = svc
6995 .handle("ahead-behind", json!({ "paths": [&path] }))
6996 .await
6997 .unwrap();
6998 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6999 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
7000 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
7001 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7002 }
7003
7004 #[tokio::test]
7005 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
7006 let dir = tempfile::tempdir().unwrap();
7008 let _repo = diverging_repo(dir.path());
7009 let svc = WorktreesService::new();
7010 svc.handle(
7011 "register",
7012 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7013 )
7014 .await
7015 .unwrap();
7016
7017 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7018 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7019 let main_wt = &worktrees[0];
7020 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
7023 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
7024 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
7025 }
7026
7027 #[tokio::test]
7028 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
7029 let dir = tempfile::tempdir().unwrap();
7034 let repo = init_repo(dir.path());
7035 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7036 repo.set_head("refs/heads/main").unwrap();
7037
7038 let svc = WorktreesService::new();
7039 svc.handle(
7040 "register",
7041 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7042 )
7043 .await
7044 .unwrap();
7045
7046 let before = svc.handle("tree", Value::Null).await.unwrap();
7047 let wt = &repos_of(&before)[0]["worktrees"][0];
7048 assert_eq!(
7049 wt.get("head_sha").and_then(Value::as_str),
7050 Some(a.to_string().as_str())
7051 );
7052
7053 let head = repo.find_commit(a).unwrap();
7056 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7057 let after = svc.handle("tree", Value::Null).await.unwrap();
7058 assert_eq!(
7059 repos_of(&after)[0]["worktrees"][0]
7060 .get("head_sha")
7061 .and_then(Value::as_str),
7062 Some(b.to_string().as_str())
7063 );
7064 assert_ne!(before, after, "a commit must be a visible snapshot delta");
7065 }
7066
7067 #[tokio::test]
7068 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7069 let dir = tempfile::tempdir().unwrap();
7072 init_repo(dir.path());
7073 let svc = WorktreesService::new();
7074 svc.handle(
7075 "register",
7076 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7077 )
7078 .await
7079 .unwrap();
7080 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7081 assert!(wt.get("head_sha").is_none(), "{wt:?}");
7082 }
7083
7084 #[tokio::test]
7087 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7088 let dir = tempfile::tempdir().unwrap();
7094 let repo = diverging_repo(dir.path());
7095 let svc = WorktreesService::new();
7096 svc.handle(
7097 "register",
7098 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7099 )
7100 .await
7101 .unwrap();
7102
7103 let before = svc.handle("tree", Value::Null).await.unwrap();
7104 let head = repo.head().unwrap().target().unwrap();
7105 assert_ne!(
7106 repos_of(&before)[0]["worktrees"][0]
7107 .get("upstream_sha")
7108 .and_then(Value::as_str),
7109 Some(head.to_string().as_str()),
7110 "the fixture must start un-pushed for this to prove anything"
7111 );
7112
7113 simulate_push(&repo, head);
7115 let after = svc.handle("tree", Value::Null).await.unwrap();
7116 let wt = &repos_of(&after)[0]["worktrees"][0];
7117 assert_eq!(
7118 wt.get("upstream_sha").and_then(Value::as_str),
7119 Some(head.to_string().as_str())
7120 );
7121 assert_eq!(
7124 wt.get("head_sha").and_then(Value::as_str),
7125 repos_of(&before)[0]["worktrees"][0]
7126 .get("head_sha")
7127 .and_then(Value::as_str)
7128 );
7129 assert_ne!(before, after, "a push must be a visible snapshot delta");
7130 }
7131
7132 #[tokio::test]
7133 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7134 let dir = tempfile::tempdir().unwrap();
7138 let repo = init_repo(dir.path());
7139 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7140 repo.set_head("refs/heads/main").unwrap();
7141 let svc = WorktreesService::new();
7142 svc.handle(
7143 "register",
7144 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7145 )
7146 .await
7147 .unwrap();
7148 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7149 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7150 assert!(wt.get("head_sha").is_some(), "{wt:?}");
7152 }
7153
7154 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7164 let guard = shim_lock();
7165 let path = dir.join("fake-gh");
7166 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7167 (path, guard)
7168 }
7169
7170 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7175 let guard = shim_lock();
7176 let path = dir.join("fake-gh");
7177 let counter = dir.join("gh-calls");
7178 write_exec_script(
7179 &path,
7180 &format!(
7181 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7182 counter = counter.display()
7183 ),
7184 );
7185 (path, guard, counter)
7186 }
7187
7188 fn gh_spawn_count(counter: &Path) -> usize {
7191 std::fs::read(counter).map_or(0, |b| b.len())
7192 }
7193
7194 fn counted_gh_records(log: &Path) -> usize {
7199 std::fs::read_to_string(log)
7200 .unwrap_or_default()
7201 .lines()
7202 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7203 .count()
7204 }
7205
7206 fn github_repo(dir: &Path) -> Repository {
7208 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7209 }
7210
7211 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7214 let repo = init_repo(dir);
7215 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7216 repo.set_head("refs/heads/main").unwrap();
7217 repo.remote("origin", url).unwrap();
7218 repo
7219 }
7220
7221 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7226 PrBadge {
7227 number,
7228 is_draft: false,
7229 checks: PrCheckState::Pending,
7230 url: "u".into(),
7231 head_oid: head_oid.to_string(),
7232 }
7233 }
7234
7235 fn pr(badge: PrBadge) -> PrResolution {
7237 PrResolution::Pr(badge)
7238 }
7239
7240 #[test]
7241 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7242 let snapshot = json!({"repos":[
7243 {
7244 "main_repo":"omni-dev",
7245 "github":{"owner":"rust-works","name":"omni-dev"},
7246 "root":"/r",
7247 "polling_enabled":true,
7250 "worktrees":[
7252 {"path":"/r","branch":"main","is_main":true,"open":true},
7253 {"path":"/w1","branch":"main","is_main":false,"open":true},
7254 {"path":"/w2","branch":"feature","is_main":false,"open":true},
7255 {"path":"/w3","is_main":false,"open":true}
7257 ]
7258 },
7259 {
7260 "main_repo":"local","root":"/l",
7262 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7263 }
7264 ]});
7265 let targets = pr_targets_from_snapshot(&snapshot);
7266 assert_eq!(
7267 targets,
7268 vec![
7269 PrTarget {
7270 owner: "rust-works".into(),
7271 name: "omni-dev".into(),
7272 branch: "feature".into()
7273 },
7274 PrTarget {
7275 owner: "rust-works".into(),
7276 name: "omni-dev".into(),
7277 branch: "main".into()
7278 },
7279 ]
7280 );
7281 }
7282
7283 #[test]
7284 fn pr_targets_from_snapshot_is_empty_without_repos() {
7285 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7286 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7287 }
7288
7289 #[test]
7290 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7291 for github in [
7294 json!({}),
7295 json!({"owner": "o"}),
7296 json!({"owner": 1, "name": 2}),
7297 ] {
7298 let snapshot = json!({"repos":[{
7299 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7300 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7301 }]});
7302 assert!(
7303 pr_targets_from_snapshot(&snapshot).is_empty(),
7304 "{snapshot:?}"
7305 );
7306 }
7307 }
7308
7309 #[test]
7310 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7311 for repo in [
7315 json!({
7316 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7317 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7318 }),
7319 json!({
7320 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7321 "polling_enabled":false,
7322 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7323 }),
7324 ] {
7325 let snapshot = json!({ "repos": [repo] });
7326 assert!(
7327 pr_targets_from_snapshot(&snapshot).is_empty(),
7328 "not-polled repo must yield no targets: {snapshot:?}"
7329 );
7330 }
7331 let enabled = json!({"repos":[{
7333 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7334 "polling_enabled":true,
7335 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7336 }]});
7337 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7338 }
7339
7340 #[test]
7341 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7342 let backoff = Duration::from_secs(600);
7343 assert!(pr_should_fetch(false, None, backoff));
7345 assert!(!pr_should_fetch(
7348 false,
7349 Some(Duration::from_secs(1)),
7350 backoff
7351 ));
7352 assert!(pr_should_fetch(false, Some(backoff), backoff));
7354 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7355 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7359 assert!(pr_should_fetch(
7360 true,
7361 Some(Duration::from_millis(1)),
7362 backoff
7363 ));
7364 }
7365
7366 #[test]
7367 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7368 let base = Duration::from_secs(10);
7369 let fresh = Some(Duration::ZERO);
7370 let stale = Some(PENDING_FAST_WINDOW);
7371 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7374 assert_eq!(
7375 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7376 base
7377 );
7378 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7381 assert_eq!(
7382 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7383 PENDING_MAX_INTERVAL
7384 );
7385 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7388 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7390 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7391 assert_eq!(
7393 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7394 MAX_PR_POLL_INTERVAL
7395 );
7396 assert_eq!(
7397 next_pr_poll_delay(Duration::MAX, base, false, None),
7398 MAX_PR_POLL_INTERVAL
7399 );
7400 }
7401
7402 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7404 PrWatch {
7405 target: PrTarget {
7406 owner: "rust-works".into(),
7407 name: "omni-dev".into(),
7408 branch: branch.into(),
7409 },
7410 upstream_sha: upstream.map(str::to_string),
7411 }
7412 }
7413
7414 #[test]
7415 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7416 let a = watch("a", Some("111"));
7417 let b = watch("b", Some("222"));
7418 let ab = [a.clone(), b.clone()];
7419 let just_a = std::slice::from_ref(&a);
7420 let just_b = std::slice::from_ref(&b);
7421 assert!(!pr_watch_grew(&ab, &ab));
7423 assert!(pr_watch_grew(just_a, &ab));
7425 assert!(!pr_watch_grew(&ab, just_a));
7427 assert!(pr_watch_grew(&[], just_a));
7429 let a_pushed = [watch("a", Some("999"))];
7431 assert!(pr_watch_grew(just_a, &a_pushed));
7432 assert!(pr_watch_grew(just_a, just_b));
7434 }
7435
7436 #[test]
7437 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7438 let base = Duration::from_secs(10);
7439 let over = RateLimitSnapshot {
7440 graphql: Some(rl_resource(90)),
7441 core: Some(rl_resource(3)),
7442 search: None,
7443 };
7444 let under = RateLimitSnapshot {
7445 graphql: Some(rl_resource(50)),
7446 core: Some(rl_resource(3)),
7447 search: None,
7448 };
7449 assert_eq!(budget_throttled_delay(base, None), base);
7451 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7453 assert_eq!(
7455 budget_throttled_delay(base, Some(&over)),
7456 BUDGET_THROTTLE_INTERVAL
7457 );
7458 let long = BUDGET_THROTTLE_INTERVAL * 2;
7460 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7461 }
7462
7463 #[test]
7464 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7465 let target = PrTarget {
7469 owner: "rust-works".into(),
7470 name: "omni-dev".into(),
7471 branch: "main".into(),
7472 };
7473 let badge = PrResolution::Pr(PrBadge {
7474 number: 1337,
7475 is_draft: true,
7476 checks: PrCheckState::Pending,
7477 url: "http://x/1337".into(),
7478 head_oid: "deadbeef".into(),
7479 });
7480 let watched = vec![watch("main", Some("abc"))];
7481 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7482 .unwrap()
7483 .with_timezone(&Utc);
7484 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7485
7486 let json = serde_json::to_vec(&prefs).unwrap();
7487 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7488 assert_eq!(back, prefs);
7489 assert_eq!(back.polled_at, Some(polled_at));
7490 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7491 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7493 }
7494
7495 #[test]
7496 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7497 let target = PrTarget {
7501 owner: "rust-works".into(),
7502 name: "omni-dev".into(),
7503 branch: "feature".into(),
7504 };
7505 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7506 let json = serde_json::to_vec(&prefs).unwrap();
7507 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7508 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7509 assert_eq!(
7510 back.entries[0].resolution.clone().into_resolution(),
7511 PrResolution::NoPr
7512 );
7513 }
7514
7515 #[test]
7516 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7517 let dir = tempfile::tempdir().unwrap();
7522 let path = dir.path().join("pr-cache.json");
7523 let target = PrTarget {
7524 owner: "rust-works".into(),
7525 name: "omni-dev".into(),
7526 branch: "main".into(),
7527 };
7528 let mut prefs = pr_cache_prefs_from(
7529 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7530 &[watch("main", None)],
7531 Utc::now(),
7532 );
7533 prefs.polled_at = None;
7534 write_pr_cache(&path, &prefs).unwrap();
7535
7536 let svc = WorktreesService::new();
7537 svc.load_pr_cache(path);
7538 assert!(
7539 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7540 "the badge itself must still restore"
7541 );
7542 assert!(
7543 svc.pr_warm_start
7544 .lock()
7545 .unwrap_or_else(PoisonError::into_inner)
7546 .is_none(),
7547 "no poll time means a cold start, not a trusted warm one"
7548 );
7549 }
7550
7551 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7555 tracing::subscriber::set_default(
7556 tracing_subscriber::fmt()
7557 .with_max_level(tracing::Level::WARN)
7558 .with_writer(std::io::sink)
7559 .finish(),
7560 )
7561 }
7562
7563 #[test]
7564 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7565 let _trace = warn_subscriber();
7569 let dir = tempfile::tempdir().unwrap();
7570 let corrupt = dir.path().join("pr-cache.json");
7571 std::fs::write(&corrupt, b"not json").unwrap();
7572 let svc = WorktreesService::new();
7573 svc.load_pr_cache(corrupt.clone());
7574 assert!(svc.pr_cache.entries().is_empty());
7575 assert_eq!(
7576 svc.pr_cache_path
7577 .lock()
7578 .unwrap_or_else(PoisonError::into_inner)
7579 .as_deref(),
7580 Some(corrupt.as_path()),
7581 "the path must be stored even when the load fails, so persistence recovers"
7582 );
7583
7584 let svc = WorktreesService::new();
7587 svc.load_pr_cache(dir.path().to_path_buf());
7588 assert!(svc.pr_cache.entries().is_empty());
7589 }
7590
7591 #[test]
7592 fn persist_pr_cache_swallows_a_write_failure() {
7593 let _trace = warn_subscriber();
7597 let dir = tempfile::tempdir().unwrap();
7598 let blocker = dir.path().join("blocker");
7599 std::fs::write(&blocker, b"").unwrap();
7600 let path = blocker.join("pr-cache.json");
7602 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7603 assert!(!path.exists());
7604 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7607 }
7608
7609 #[tokio::test]
7610 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7611 let dir = tempfile::tempdir().unwrap();
7612 let repo = github_repo(dir.path());
7613 let head = repo.head().unwrap().target().unwrap().to_string();
7614 let svc = WorktreesService::new();
7615 svc.handle(
7616 "register",
7617 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7618 )
7619 .await
7620 .unwrap();
7621 svc.registry.set_polling("rust-works", "omni-dev", true);
7624
7625 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7628 assert!(wt.get("pr").is_none(), "{wt:?}");
7629 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7630
7631 let mut badges = HashMap::new();
7633 badges.insert(
7634 PrTarget {
7635 owner: "rust-works".into(),
7636 name: "omni-dev".into(),
7637 branch: "main".into(),
7638 },
7639 pr(pending_badge(1337, &head)),
7640 );
7641 assert!(svc.pr_cache.replace(badges));
7642
7643 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7644 assert_eq!(wt["pr"]["number"], json!(1337));
7645 assert_eq!(wt["pr"]["checks"], json!("pending"));
7646 assert_eq!(wt["pr"]["isDraft"], json!(false));
7648 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7650 }
7651
7652 #[tokio::test]
7653 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7654 let dir = tempfile::tempdir().unwrap();
7659 let repo = github_repo(dir.path());
7660 let head = repo.head().unwrap().target().unwrap();
7661 repo.set_head_detached(head).unwrap();
7662
7663 let svc = WorktreesService::new();
7664 svc.handle(
7665 "register",
7666 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7667 )
7668 .await
7669 .unwrap();
7670 svc.registry.set_polling("rust-works", "omni-dev", true);
7673 let mut badges = HashMap::new();
7676 badges.insert(
7677 PrTarget {
7678 owner: "rust-works".into(),
7679 name: "omni-dev".into(),
7680 branch: "main".into(),
7681 },
7682 pr(pending_badge(1, &head.to_string())),
7683 );
7684 svc.pr_cache.replace(badges);
7685
7686 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7687 assert!(wt.get("branch").is_none(), "{wt:?}");
7688 assert_eq!(
7690 wt.get("head_sha").and_then(Value::as_str),
7691 Some(head.to_string().as_str())
7692 );
7693 assert!(wt.get("pr").is_none(), "{wt:?}");
7694 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7696 }
7697
7698 #[tokio::test]
7699 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7700 let dir = tempfile::tempdir().unwrap();
7701 github_repo(dir.path());
7702 let svc = WorktreesService::new();
7703 svc.handle(
7704 "register",
7705 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7706 )
7707 .await
7708 .unwrap();
7709 svc.registry.set_polling("rust-works", "omni-dev", true);
7712 let mut badges = HashMap::new();
7714 badges.insert(
7715 PrTarget {
7716 owner: "rust-works".into(),
7717 name: "omni-dev".into(),
7718 branch: "other".into(),
7719 },
7720 pr(pending_badge(1, "irrelevant")),
7721 );
7722 svc.pr_cache.replace(badges);
7723 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7724 assert!(wt.get("pr").is_none(), "{wt:?}");
7725 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7727 }
7728
7729 #[tokio::test]
7730 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7731 let dir = tempfile::tempdir().unwrap();
7735 github_repo(dir.path());
7736 let svc = WorktreesService::new();
7737 svc.handle(
7738 "register",
7739 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7740 )
7741 .await
7742 .unwrap();
7743 svc.registry.set_polling("rust-works", "omni-dev", true);
7746
7747 let mut resolutions = HashMap::new();
7748 resolutions.insert(
7749 PrTarget {
7750 owner: "rust-works".into(),
7751 name: "omni-dev".into(),
7752 branch: "main".into(),
7753 },
7754 PrResolution::NoPr,
7755 );
7756 assert!(svc.pr_cache.replace(resolutions));
7757
7758 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7759 assert_eq!(wt["pr_none"], json!(true));
7760 assert!(wt.get("pr").is_none(), "{wt:?}");
7762 }
7763
7764 #[tokio::test]
7765 async fn a_commit_does_not_drop_a_negative_resolution() {
7766 let dir = tempfile::tempdir().unwrap();
7771 let repo = github_repo(dir.path());
7772 let first = repo.head().unwrap().target().unwrap();
7773
7774 let svc = WorktreesService::new();
7775 svc.handle(
7776 "register",
7777 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7778 )
7779 .await
7780 .unwrap();
7781 svc.registry.set_polling("rust-works", "omni-dev", true);
7784
7785 let mut resolutions = HashMap::new();
7786 resolutions.insert(
7787 PrTarget {
7788 owner: "rust-works".into(),
7789 name: "omni-dev".into(),
7790 branch: "main".into(),
7791 },
7792 PrResolution::NoPr,
7793 );
7794 svc.pr_cache.replace(resolutions);
7795
7796 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7797 assert_eq!(wt["pr_none"], json!(true));
7798
7799 let head = repo.find_commit(first).unwrap();
7801 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7802
7803 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7804 assert_eq!(
7805 wt["pr_none"],
7806 json!(true),
7807 "a local commit must not drop the negative"
7808 );
7809 }
7810
7811 #[tokio::test]
7812 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7813 let bin_dir = tempfile::tempdir().unwrap();
7817 let marker = bin_dir.path().join("spawned");
7818 let fake = bin_dir.path().join("fake-gh");
7819 std::fs::write(
7820 &fake,
7821 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7822 )
7823 .unwrap();
7824 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7825 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7826 std::fs::set_permissions(&fake, perms).unwrap();
7827
7828 let svc = WorktreesService::new();
7829 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7830 tokio::time::sleep(Duration::from_millis(200)).await;
7831 svc.shutdown().await;
7832 assert!(
7833 !marker.exists(),
7834 "the poller must not spawn gh with no windows registered"
7835 );
7836 }
7837
7838 #[tokio::test]
7839 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7840 let dir = tempfile::tempdir().unwrap();
7843 let repo = github_repo(dir.path());
7844 let head = repo.head().unwrap().target().unwrap().to_string();
7845 let bin_dir = tempfile::tempdir().unwrap();
7846 let fake = bin_dir.path().join("fake-gh");
7847 std::fs::write(
7849 &fake,
7850 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7851 )
7852 .unwrap();
7853 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7854 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7855 std::fs::set_permissions(&fake, perms).unwrap();
7856
7857 let svc = WorktreesService::new();
7858 svc.handle(
7859 "register",
7860 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7861 )
7862 .await
7863 .unwrap();
7864 svc.registry.set_polling("rust-works", "omni-dev", true);
7867 let mut seeded = HashMap::new();
7869 seeded.insert(
7870 PrTarget {
7871 owner: "rust-works".into(),
7872 name: "omni-dev".into(),
7873 branch: "main".into(),
7874 },
7875 pr(pending_badge(7, &head)),
7876 );
7877 svc.pr_cache.replace(seeded);
7878
7879 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7880 tokio::time::sleep(Duration::from_millis(200)).await;
7881
7882 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7885 assert_eq!(wt["pr"]["number"], json!(7));
7886 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7887 svc.shutdown().await;
7888 }
7889
7890 #[tokio::test]
7891 #[allow(clippy::await_holding_lock)]
7898 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7899 let dir = tempfile::tempdir().unwrap();
7905 github_repo(dir.path());
7906 let bin_dir = tempfile::tempdir().unwrap();
7907 let (fake, _shim) = fake_gh(
7908 bin_dir.path(),
7909 r#"{"data":{"r0":{"b0":{
7910 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7911 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7912 ]}}},
7913 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7914 }}}}"#,
7915 );
7916
7917 let svc = WorktreesService::new();
7918 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7920 tokio::time::sleep(Duration::from_millis(150)).await;
7921
7922 svc.handle(
7924 "register",
7925 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7926 )
7927 .await
7928 .unwrap();
7929 svc.registry.set_polling("rust-works", "omni-dev", true);
7932
7933 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7937 loop {
7938 if let Some(PrResolution::Pr(badge)) =
7939 svc.pr_cache.get("rust-works", "omni-dev", "main")
7940 {
7941 return badge;
7942 }
7943 tokio::time::sleep(Duration::from_millis(25)).await;
7944 }
7945 })
7946 .await
7947 .expect("a window opening must wake the poller out of its idle backoff");
7948 assert_eq!(badge.number, 99);
7949 svc.shutdown().await;
7950 }
7951
7952 #[tokio::test]
7953 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7954 let dir = tempfile::tempdir().unwrap();
7961 let repo = github_repo(dir.path());
7962 let first = repo.head().unwrap().target().unwrap();
7963
7964 let svc = WorktreesService::new();
7965 svc.handle(
7966 "register",
7967 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7968 )
7969 .await
7970 .unwrap();
7971 svc.registry.set_polling("rust-works", "omni-dev", true);
7974
7975 let mut badges = HashMap::new();
7977 badges.insert(
7978 PrTarget {
7979 owner: "rust-works".into(),
7980 name: "omni-dev".into(),
7981 branch: "main".into(),
7982 },
7983 pr(PrBadge {
7984 number: 1337,
7985 is_draft: false,
7986 checks: PrCheckState::Success,
7987 url: "u".into(),
7988 head_oid: first.to_string(),
7989 }),
7990 );
7991 svc.pr_cache.replace(badges);
7992
7993 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7994 assert_eq!(
7995 wt["pr"]["checks"],
7996 json!("success"),
7997 "green for its own commit"
7998 );
7999
8000 let head = repo.find_commit(first).unwrap();
8002 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8003
8004 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8005 assert_eq!(
8006 wt["pr"]["checks"],
8007 json!("pending"),
8008 "the previous commit's ✓ must not stand after a new commit"
8009 );
8010 assert_eq!(wt["pr"]["number"], json!(1337));
8013 }
8014
8015 #[test]
8016 fn is_stale_for_compares_the_commit_the_verdict_describes() {
8017 let badge = pending_badge(1, "aaa");
8018 assert!(!badge.is_stale_for(Some("aaa")));
8019 assert!(badge.is_stale_for(Some("bbb")));
8020 assert!(!badge.is_stale_for(None));
8022 }
8023
8024 #[test]
8025 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
8026 let snap = |sha: &str| {
8031 json!({"repos":[{
8032 "main_repo":"omni-dev",
8033 "github":{"owner":"rust-works","name":"omni-dev"},
8034 "root":"/r",
8035 "polling_enabled":true,
8036 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
8037 }]})
8038 };
8039 let before = pr_watch_from_snapshot(&snap("aaa"));
8040 let after = pr_watch_from_snapshot(&snap("bbb"));
8041 assert_eq!(before.len(), 1);
8042 assert_eq!(before[0].target, after[0].target);
8043 assert_eq!(before, after);
8045 assert!(!pr_watch_grew(&before, &after));
8046 }
8047
8048 #[test]
8049 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
8050 let snap = |upstream: &str| {
8054 json!({"repos":[{
8055 "main_repo":"omni-dev",
8056 "github":{"owner":"rust-works","name":"omni-dev"},
8057 "root":"/r",
8058 "polling_enabled":true,
8059 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
8060 "upstream_sha":upstream,"is_main":true,"open":true}]
8061 }]})
8062 };
8063 let before = pr_watch_from_snapshot(&snap("aaa"));
8064 let after = pr_watch_from_snapshot(&snap("bbb"));
8065 assert_eq!(before.len(), 1);
8067 assert_eq!(before[0].target, after[0].target);
8068 assert_ne!(before, after);
8069 assert!(pr_watch_grew(&before, &after));
8070 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8072 assert!(!pr_watch_grew(
8073 &before,
8074 &pr_watch_from_snapshot(&snap("aaa"))
8075 ));
8076 }
8077
8078 #[test]
8079 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8080 let snap = json!({"repos":[{
8083 "main_repo":"omni-dev",
8084 "github":{"owner":"rust-works","name":"omni-dev"},
8085 "root":"/r",
8086 "polling_enabled":true,
8087 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8088 }]});
8089 let watch = pr_watch_from_snapshot(&snap);
8090 assert_eq!(watch.len(), 1);
8091 assert_eq!(watch[0].upstream_sha, None);
8092 }
8093
8094 #[test]
8095 fn start_pr_poller_is_a_noop_outside_a_runtime() {
8096 let svc = WorktreesService::new();
8097 svc.start_pr_poller();
8098 assert!(svc
8099 .poller
8100 .lock()
8101 .unwrap_or_else(PoisonError::into_inner)
8102 .is_none());
8103 }
8104
8105 #[tokio::test]
8106 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8107 let svc = WorktreesService::new();
8108 svc.start_pr_poller_with(
8109 Duration::from_millis(50),
8110 Duration::from_millis(10),
8111 PathBuf::from("/bin/true"),
8112 );
8113 let token = svc
8114 .poller
8115 .lock()
8116 .unwrap_or_else(PoisonError::into_inner)
8117 .as_ref()
8118 .map(|t| t.token.clone())
8119 .expect("poller started");
8120
8121 token.cancel();
8124 svc.start_pr_poller_with(
8125 Duration::from_millis(50),
8126 Duration::from_millis(10),
8127 PathBuf::from("/bin/true"),
8128 );
8129 assert!(svc
8130 .poller
8131 .lock()
8132 .unwrap_or_else(PoisonError::into_inner)
8133 .as_ref()
8134 .is_some_and(|t| t.token.is_cancelled()));
8135
8136 svc.shutdown().await;
8137 assert!(svc
8138 .poller
8139 .lock()
8140 .unwrap_or_else(PoisonError::into_inner)
8141 .is_none());
8142 }
8143
8144 fn rl_resource(used: u64) -> RateLimitResource {
8148 RateLimitResource {
8149 used,
8150 limit: 100,
8151 remaining: 100 - used,
8152 percent: used as f64,
8153 reset: 0,
8154 }
8155 }
8156
8157 #[test]
8158 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8159 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8160 graphql: Some(rl_resource(graphql)),
8161 core: Some(rl_resource(core)),
8162 search: None,
8163 };
8164 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8166 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8168 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8170 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8172 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8174 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8176 }
8177
8178 #[test]
8179 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8180 let svc = WorktreesService::new();
8181 svc.start_rate_limit_poller();
8182 assert!(svc
8183 .rate_limit_poller
8184 .lock()
8185 .unwrap_or_else(PoisonError::into_inner)
8186 .is_none());
8187 }
8188
8189 #[tokio::test]
8190 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8191 let svc = WorktreesService::new();
8192 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8193 let token = svc
8194 .rate_limit_poller
8195 .lock()
8196 .unwrap_or_else(PoisonError::into_inner)
8197 .as_ref()
8198 .map(|t| t.token.clone())
8199 .expect("poller started");
8200
8201 token.cancel();
8204 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8205 assert!(svc
8206 .rate_limit_poller
8207 .lock()
8208 .unwrap_or_else(PoisonError::into_inner)
8209 .as_ref()
8210 .is_some_and(|t| t.token.is_cancelled()));
8211
8212 svc.shutdown().await;
8213 assert!(svc
8214 .rate_limit_poller
8215 .lock()
8216 .unwrap_or_else(PoisonError::into_inner)
8217 .is_none());
8218 }
8219
8220 #[tokio::test]
8221 #[allow(clippy::await_holding_lock)]
8223 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8224 let bin_dir = tempfile::tempdir().unwrap();
8225 let (fake, _shim) = fake_gh(
8226 bin_dir.path(),
8227 r#"{"resources":{
8228 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8229 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8230 }}"#,
8231 );
8232 let svc = WorktreesService::new();
8233 svc.registry.set_polling("rust-works", "omni-dev", true);
8236 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8237
8238 let snap = tokio::time::timeout(Duration::from_secs(30), async {
8241 loop {
8242 if let Some(snap) = svc.rate_limit_cache.get() {
8243 return snap;
8244 }
8245 tokio::time::sleep(Duration::from_millis(25)).await;
8246 }
8247 })
8248 .await
8249 .expect("poller should populate the cache through the fake gh");
8250 assert_eq!(snap.graphql.unwrap().used, 4100);
8251 assert_eq!(snap.core.unwrap().used, 27);
8252
8253 assert!(svc.rate_limit_cache().get().is_some());
8255
8256 svc.shutdown().await;
8257 assert!(svc
8258 .rate_limit_poller
8259 .lock()
8260 .unwrap_or_else(PoisonError::into_inner)
8261 .is_none());
8262 }
8263
8264 #[tokio::test]
8265 #[allow(clippy::await_holding_lock)]
8267 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8268 let bin_dir = tempfile::tempdir().unwrap();
8271 let (fake, _shim, counter) = counting_fake_gh(
8272 bin_dir.path(),
8273 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8274 );
8275 let svc = WorktreesService::new();
8276 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8277
8278 tokio::time::sleep(Duration::from_millis(300)).await;
8280 assert_eq!(
8281 gh_spawn_count(&counter),
8282 0,
8283 "idle daemon must not poll (#1389, fix 8b)"
8284 );
8285 assert!(svc.rate_limit_cache.get().is_none());
8286
8287 svc.registry.set_polling("rust-works", "omni-dev", true);
8289 tokio::time::timeout(Duration::from_secs(30), async {
8290 loop {
8291 if svc.rate_limit_cache.get().is_some() {
8292 return;
8293 }
8294 tokio::time::sleep(Duration::from_millis(25)).await;
8295 }
8296 })
8297 .await
8298 .expect("an active lease should resume polling");
8299 assert!(gh_spawn_count(&counter) >= 1);
8300 svc.shutdown().await;
8301 }
8302
8303 #[tokio::test]
8304 async fn rate_limit_poller_survives_a_failing_gh() {
8305 let svc = WorktreesService::new();
8309 svc.registry.set_polling("rust-works", "omni-dev", true);
8310 svc.start_rate_limit_poller_with(
8311 Duration::from_millis(20),
8312 PathBuf::from("/no/such/gh/xyzzy"),
8313 );
8314 tokio::time::sleep(Duration::from_millis(150)).await;
8316 assert!(svc.rate_limit_cache.get().is_none());
8317 assert!(
8318 svc.rate_limit_poller
8319 .lock()
8320 .unwrap_or_else(PoisonError::into_inner)
8321 .is_some(),
8322 "the loop must survive a failing gh, not panic out"
8323 );
8324 svc.shutdown().await;
8325 }
8326
8327 #[test]
8328 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8329 let svc = WorktreesService::new();
8330 let items = svc.menu().items;
8332 assert!(
8333 !items
8334 .iter()
8335 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8336 "no github line before the first poll"
8337 );
8338
8339 svc.rate_limit_cache.replace(RateLimitSnapshot {
8341 graphql: Some(rl_resource(82)),
8342 core: Some(rl_resource(3)),
8343 search: None,
8344 });
8345 let items = svc.menu().items;
8346 assert!(
8347 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8348 "expected the github line first, got {items:?}"
8349 );
8350 assert!(
8351 matches!(items.get(1), Some(MenuItem::Separator)),
8352 "expected a separator after the github line"
8353 );
8354 }
8355
8356 #[tokio::test]
8357 #[allow(clippy::await_holding_lock)]
8359 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8360 let dir = tempfile::tempdir().unwrap();
8361 github_repo(dir.path());
8362 let bin_dir = tempfile::tempdir().unwrap();
8363 let (fake, _shim) = fake_gh(
8366 bin_dir.path(),
8367 r#"{"data":{"r0":{"b0":{
8368 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8369 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8370 ]}}},
8371 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8372 }}}}"#,
8373 );
8374 let svc = WorktreesService::new();
8375 svc.handle(
8376 "register",
8377 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8378 )
8379 .await
8380 .unwrap();
8381 svc.registry.set_polling("rust-works", "omni-dev", true);
8384 svc.start_pr_poller_with(
8385 Duration::from_millis(50),
8386 Duration::from_millis(10),
8387 fake.clone(),
8388 );
8389
8390 let badge = tokio::time::timeout(Duration::from_secs(30), async {
8394 loop {
8395 if let Some(PrResolution::Pr(badge)) =
8396 svc.pr_cache.get("rust-works", "omni-dev", "main")
8397 {
8398 return badge;
8399 }
8400 tokio::time::sleep(Duration::from_millis(25)).await;
8401 }
8402 })
8403 .await
8404 .expect("poller should resolve a badge through the fake gh");
8405 assert_eq!(badge.number, 1337);
8406 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8407
8408 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8410 assert_eq!(wt["pr"]["number"], json!(1337));
8411
8412 svc.shutdown().await;
8414 let generation = svc.registry.change_generation();
8415 tokio::time::sleep(Duration::from_millis(120)).await;
8416 assert_eq!(
8417 svc.registry.change_generation(),
8418 generation,
8419 "no bumps after shutdown"
8420 );
8421 }
8422
8423 #[tokio::test]
8424 #[allow(clippy::await_holding_lock)]
8426 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8427 let dir = tempfile::tempdir().unwrap();
8431 github_repo(dir.path());
8432 let bin_dir = tempfile::tempdir().unwrap();
8433 let (fake, _shim) = fake_gh(
8434 bin_dir.path(),
8435 r#"{"data":{
8436 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8437 "resetAt":"2026-07-21T16:00:00Z"},
8438 "r0":{"b0":{
8439 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8440 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8441 ]}}},
8442 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8443 }}
8444 }}"#,
8445 );
8446 let svc = WorktreesService::new();
8447 svc.handle(
8448 "register",
8449 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8450 )
8451 .await
8452 .unwrap();
8453 svc.registry.set_polling("rust-works", "omni-dev", true);
8454 svc.start_pr_poller_with(
8457 Duration::from_millis(50),
8458 Duration::from_millis(10),
8459 fake.clone(),
8460 );
8461
8462 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8463 loop {
8464 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8465 return g;
8466 }
8467 tokio::time::sleep(Duration::from_millis(25)).await;
8468 }
8469 })
8470 .await
8471 .expect("the PR poll should fold its budget into the cache");
8472 assert_eq!(graphql.used, 123);
8473 assert_eq!(graphql.limit, 5000);
8474 assert_eq!(graphql.remaining, 4877);
8475 svc.shutdown().await;
8476 }
8477
8478 #[tokio::test]
8479 #[allow(clippy::await_holding_lock)]
8481 async fn pr_poll_counts_every_gh_call_exactly_once() {
8482 let dir = tempfile::tempdir().unwrap();
8489 github_repo(dir.path());
8490 let bin_dir = tempfile::tempdir().unwrap();
8491 let (fake, _shim, counter) = counting_fake_gh(
8492 bin_dir.path(),
8493 r#"{"data":{"r0":{"b0":{
8494 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8495 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8496 ]}}},
8497 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8498 }}}}"#,
8499 );
8500 let log = bin_dir.path().join("log.jsonl");
8501 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8502
8503 let svc = WorktreesService::new();
8504 svc.handle(
8505 "register",
8506 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8507 )
8508 .await
8509 .unwrap();
8510 svc.registry.set_polling("rust-works", "omni-dev", true);
8511 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8512
8513 tokio::time::timeout(Duration::from_secs(30), async {
8515 loop {
8516 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8517 return;
8518 }
8519 tokio::time::sleep(Duration::from_millis(25)).await;
8520 }
8521 })
8522 .await
8523 .expect("poller should fetch through the fake gh");
8524
8525 svc.shutdown().await;
8527 let spawns = gh_spawn_count(&counter);
8528 let counted = counted_gh_records(&log);
8529 std::env::remove_var("OMNI_DEV_LOG_FILE");
8530 assert!(
8531 spawns >= 1,
8532 "the poll should have spent at least one gh call"
8533 );
8534 assert_eq!(
8535 counted, spawns,
8536 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8537 );
8538 }
8539
8540 #[tokio::test]
8541 #[allow(clippy::await_holding_lock)]
8543 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8544 let dir_a = tempfile::tempdir().unwrap();
8549 let dir_b = tempfile::tempdir().unwrap();
8550 github_repo(dir_a.path()); github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); let bin_dir = tempfile::tempdir().unwrap();
8553 let (fake, _shim, counter) = counting_fake_gh(
8555 bin_dir.path(),
8556 r#"{"data":{
8557 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8558 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8559 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8560 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8561 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8562 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8563 }}"#,
8564 );
8565 let svc = WorktreesService::new();
8566 svc.registry.set_polling("rust-works", "omni-dev", true);
8569 svc.registry.set_polling("rust-works", "other-repo", true);
8570 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8574 svc.handle(
8576 "register",
8577 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8578 )
8579 .await
8580 .unwrap();
8581 tokio::time::sleep(Duration::from_millis(50)).await;
8585 svc.handle(
8586 "register",
8587 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8588 )
8589 .await
8590 .unwrap();
8591
8592 tokio::time::timeout(Duration::from_secs(30), async {
8595 loop {
8596 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8597 let b = svc
8598 .pr_cache
8599 .get("rust-works", "other-repo", "main")
8600 .is_some();
8601 if a && b {
8602 return;
8603 }
8604 tokio::time::sleep(Duration::from_millis(25)).await;
8605 }
8606 })
8607 .await
8608 .expect("the debounced fetch should resolve both repos");
8609
8610 svc.shutdown().await;
8611 assert_eq!(
8612 gh_spawn_count(&counter),
8613 1,
8614 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8615 );
8616 }
8617
8618 #[tokio::test]
8619 #[allow(clippy::await_holding_lock)]
8621 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8622 let dir = tempfile::tempdir().unwrap();
8627 github_repo(dir.path());
8628 let bin_dir = tempfile::tempdir().unwrap();
8629 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8630 let svc = WorktreesService::new();
8631 svc.registry.set_polling("rust-works", "omni-dev", true);
8632 svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8636 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8637 svc.handle("register", register.clone()).await.unwrap();
8638 let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8648 loop {
8649 tokio::time::sleep(Duration::from_millis(25)).await;
8650 svc.handle("register", register.clone()).await.unwrap();
8651 if gh_spawn_count(&counter) >= 1 {
8652 return;
8653 }
8654 }
8655 })
8656 .await;
8657 svc.shutdown().await;
8658 forced_mid_drip.expect(
8659 "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8660 );
8661 }
8662
8663 #[tokio::test]
8664 #[allow(clippy::await_holding_lock)]
8666 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8667 let dir = tempfile::tempdir().unwrap();
8671 let repo = github_repo(dir.path());
8672 let head = repo.head().unwrap().target().unwrap().to_string();
8673 let bin_dir = tempfile::tempdir().unwrap();
8674 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8677
8678 let cache_path = bin_dir.path().join("pr-cache.json");
8682 let target = PrTarget {
8683 owner: "rust-works".into(),
8684 name: "omni-dev".into(),
8685 branch: "main".into(),
8686 };
8687 let prefs = pr_cache_prefs_from(
8688 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8689 &[watch("main", None)],
8690 Utc::now(),
8691 );
8692 write_pr_cache(&cache_path, &prefs).unwrap();
8693
8694 let svc = WorktreesService::new();
8695 svc.load_pr_cache(cache_path);
8696 svc.registry.set_polling("rust-works", "omni-dev", true);
8697 svc.handle(
8698 "register",
8699 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8700 )
8701 .await
8702 .unwrap();
8703 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8706
8707 let number = tokio::time::timeout(Duration::from_secs(30), async {
8709 loop {
8710 let tree = svc.handle("tree", Value::Null).await.unwrap();
8711 if let Some(n) = repos_of(&tree)
8712 .first()
8713 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8714 {
8715 return n;
8716 }
8717 tokio::time::sleep(Duration::from_millis(25)).await;
8718 }
8719 })
8720 .await
8721 .expect("the restored badge should render from the warm cache");
8722 assert_eq!(number, 1337);
8723
8724 tokio::time::sleep(Duration::from_millis(300)).await;
8726 svc.shutdown().await;
8727 assert_eq!(
8728 gh_spawn_count(&counter),
8729 0,
8730 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8731 );
8732 }
8733
8734 #[tokio::test]
8735 #[allow(clippy::await_holding_lock)]
8737 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8738 let dir = tempfile::tempdir().unwrap();
8743 github_repo(dir.path());
8744 let bin_dir = tempfile::tempdir().unwrap();
8745 let (fake, _shim) = fake_gh(
8746 bin_dir.path(),
8747 r#"{"data":{"r0":{"b0":{
8748 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8749 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8750 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8751 }}}}"#,
8752 );
8753 let svc = WorktreesService::new();
8754 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8757 svc.load_pr_cache(cache_path.clone());
8758 svc.registry.set_polling("rust-works", "omni-dev", true);
8759 svc.handle(
8760 "register",
8761 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8762 )
8763 .await
8764 .unwrap();
8765 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8766
8767 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8770 loop {
8771 if let Ok(bytes) = std::fs::read(&cache_path) {
8772 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8773 if !prefs.entries.is_empty() {
8774 return prefs;
8775 }
8776 }
8777 }
8778 tokio::time::sleep(Duration::from_millis(25)).await;
8779 }
8780 })
8781 .await
8782 .expect("a successful resolve should persist the cache file");
8783 svc.shutdown().await;
8784
8785 assert_eq!(prefs.entries[0].target.branch, "main");
8786 assert!(
8787 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8788 "{:?}",
8789 prefs.entries[0].resolution
8790 );
8791 assert_eq!(
8792 prefs.watched,
8793 vec![PersistedWatch {
8794 target: prefs.entries[0].target.clone(),
8795 upstream_sha: None
8796 }]
8797 );
8798 assert!(
8799 prefs.polled_at.is_some(),
8800 "the poll time is what ages the next warm start"
8801 );
8802 }
8803
8804 #[tokio::test]
8805 #[allow(clippy::await_holding_lock)]
8807 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8808 let bin_dir = tempfile::tempdir().unwrap();
8812 let (fake, _shim, counter) = counting_fake_gh(
8813 bin_dir.path(),
8814 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8815 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8816 );
8817 let svc = WorktreesService::new();
8818
8819 let prs = svc
8820 .open_prs_with("rust-works", "omni-dev", fake.clone())
8821 .await
8822 .expect("gh pr list should resolve");
8823 assert_eq!(prs.len(), 1);
8824 assert_eq!(prs[0]["number"], json!(42));
8825 assert_eq!(prs[0]["url"], json!("http://x/42"));
8826 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8827
8828 let again = svc
8830 .open_prs_with("rust-works", "omni-dev", fake.clone())
8831 .await
8832 .expect("cache hit should resolve");
8833 assert_eq!(again, prs);
8834 assert_eq!(
8835 gh_spawn_count(&counter),
8836 1,
8837 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8838 );
8839
8840 let reply = svc
8842 .handle(
8843 "open-prs",
8844 json!({ "owner": "rust-works", "name": "omni-dev" }),
8845 )
8846 .await
8847 .expect("open-prs op should route");
8848 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8849 assert!(svc
8850 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
8851 .await
8852 .is_err());
8853 }
8854
8855 #[test]
8856 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8857 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8862 assert!(
8863 err.to_string().contains("is the GitHub CLI installed"),
8864 "{err:#}"
8865 );
8866
8867 let bin_dir = tempfile::tempdir().unwrap();
8868 let _guard = shim_lock();
8869 let failing = bin_dir.path().join("fake-gh-fails");
8870 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8871 let err = retry_on_etxtbsy(|| open_pr_list(&failing, "rust-works/omni-dev")).unwrap_err();
8872 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8873 assert!(err.to_string().contains("boom"), "{err:#}");
8874
8875 let object = bin_dir.path().join("fake-gh-object");
8876 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8877 let err = retry_on_etxtbsy(|| open_pr_list(&object, "rust-works/omni-dev")).unwrap_err();
8878 assert!(
8879 err.to_string().contains("did not return a JSON array"),
8880 "{err:#}"
8881 );
8882 }
8883
8884 #[tokio::test]
8885 #[allow(clippy::await_holding_lock)]
8887 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8888 let dir = tempfile::tempdir().unwrap();
8894 github_repo(dir.path());
8895 let bin_dir = tempfile::tempdir().unwrap();
8896 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8897
8898 let svc = WorktreesService::new();
8899 *svc.pr_warm_start
8903 .lock()
8904 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8905 watched: vec![],
8906 polled_at: Utc::now(),
8907 });
8908 svc.rate_limit_cache.replace(RateLimitSnapshot {
8910 graphql: Some(rl_resource(90)),
8911 core: Some(rl_resource(3)),
8912 search: None,
8913 });
8914 svc.registry.set_polling("rust-works", "omni-dev", true);
8915 svc.handle(
8916 "register",
8917 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8918 )
8919 .await
8920 .unwrap();
8921 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8924
8925 tokio::time::sleep(Duration::from_millis(300)).await;
8927 svc.shutdown().await;
8928 assert_eq!(
8929 gh_spawn_count(&counter),
8930 0,
8931 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8932 );
8933 }
8934
8935 #[tokio::test]
8936 #[allow(clippy::await_holding_lock)]
8938 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8939 let dir = tempfile::tempdir().unwrap();
8942 github_repo(dir.path());
8943 let bin_dir = tempfile::tempdir().unwrap();
8944 let (fake, _shim) = fake_gh(
8945 bin_dir.path(),
8946 r#"{"data":{"r0":{"b0":{
8947 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8948 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8949 ]}}},
8950 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8951 }}}}"#,
8952 );
8953 let svc = WorktreesService::new();
8954 svc.handle(
8955 "register",
8956 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8957 )
8958 .await
8959 .unwrap();
8960 svc.registry.set_polling("rust-works", "omni-dev", true);
8963 svc.start_pr_poller_with(
8964 Duration::from_millis(50),
8965 Duration::from_millis(10),
8966 fake.clone(),
8967 );
8968
8969 tokio::time::timeout(Duration::from_secs(30), async {
8970 loop {
8971 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8972 return;
8973 }
8974 tokio::time::sleep(Duration::from_millis(25)).await;
8975 }
8976 })
8977 .await
8978 .expect("poller should resolve a badge through the fake gh");
8979 let settled = svc.registry.change_generation();
8982 tokio::time::sleep(Duration::from_millis(150)).await;
8983 assert_eq!(
8984 svc.registry.change_generation(),
8985 settled,
8986 "an unchanged poll must not bump the change-notify"
8987 );
8988 svc.shutdown().await;
8989 }
8990
8991 #[tokio::test]
8992 #[allow(clippy::await_holding_lock)]
8994 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8995 let dir = tempfile::tempdir().unwrap();
9000 github_repo(dir.path());
9001 let bin_dir = tempfile::tempdir().unwrap();
9002 let (fake, _shim) = fake_gh(
9003 bin_dir.path(),
9004 r#"{"data":{"r0":{"b0":{
9005 "target":{"oid":"abc","statusCheckRollup":null},
9006 "associatedPullRequests":{"nodes":[]}
9007 }}}}"#,
9008 );
9009 let svc = WorktreesService::new();
9010 svc.handle(
9011 "register",
9012 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9013 )
9014 .await
9015 .unwrap();
9016 svc.registry.set_polling("rust-works", "omni-dev", true);
9019 svc.start_pr_poller_with(
9020 Duration::from_millis(50),
9021 Duration::from_millis(10),
9022 fake.clone(),
9023 );
9024
9025 tokio::time::timeout(Duration::from_secs(30), async {
9026 loop {
9027 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
9028 return;
9029 }
9030 tokio::time::sleep(Duration::from_millis(25)).await;
9031 }
9032 })
9033 .await
9034 .expect("poller should resolve the negative through the fake gh");
9035
9036 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9038 assert_eq!(wt["pr_none"], json!(true));
9039 assert!(wt.get("pr").is_none(), "{wt:?}");
9040
9041 let settled = svc.registry.change_generation();
9043 tokio::time::sleep(Duration::from_millis(150)).await;
9044 assert_eq!(
9045 svc.registry.change_generation(),
9046 settled,
9047 "an unchanged negative must not bump the change-notify"
9048 );
9049 svc.shutdown().await;
9050 }
9051
9052 #[test]
9053 fn sync_indicator_formats_only_with_upstream() {
9054 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
9055 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
9056 assert_eq!(sync_indicator(None, None), None);
9057 assert_eq!(sync_indicator(Some(1), None), None);
9059 }
9060
9061 #[tokio::test]
9062 async fn list_enriches_entries_with_git_status() {
9063 let dir = tempfile::tempdir().unwrap();
9064 let repo = init_repo(dir.path());
9065 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9066 repo.set_head("refs/heads/main").unwrap();
9067
9068 let svc = WorktreesService::new();
9069 svc.handle(
9070 "register",
9071 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9072 )
9073 .await
9074 .unwrap();
9075 let payload = svc.handle("list", Value::Null).await.unwrap();
9076 let windows = windows_of(&payload);
9077 assert_eq!(windows.len(), 1);
9078 assert_eq!(
9079 windows[0].get("branch").and_then(Value::as_str),
9080 Some("main")
9081 );
9082 assert!(windows[0].get("ahead").is_none());
9084 assert!(windows[0].get("behind").is_none());
9085 assert_eq!(
9087 windows[0].get("main_repo").and_then(Value::as_str),
9088 dir.path().file_name().and_then(|n| n.to_str())
9089 );
9090
9091 let plain = tempfile::tempdir().unwrap();
9093 svc.handle(
9094 "register",
9095 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9096 )
9097 .await
9098 .unwrap();
9099 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9100 let w2 = windows
9101 .iter()
9102 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9103 .unwrap();
9104 assert!(w2.get("branch").is_none());
9105 assert!(w2.get("main_repo").is_none());
9106 }
9107
9108 #[test]
9109 fn window_label_prefers_git_branch_over_title() {
9110 let dir = tempfile::tempdir().unwrap();
9111 let repo = init_repo(dir.path());
9112 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9113 repo.set_head("refs/heads/main").unwrap();
9114 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9115 let entry = WindowEntry {
9116 key: "k".to_string(),
9117 folders: vec![dir.path().to_path_buf()],
9118 repo: Some("companion-repo".to_string()),
9121 title: Some("ignored title".to_string()),
9122 pid: None,
9123 last_seen: Utc::now(),
9124 };
9125 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9127 }
9128
9129 #[tokio::test]
9130 async fn list_includes_ahead_behind_for_tracking_branch() {
9131 let dir = tempfile::tempdir().unwrap();
9132 let _repo = diverging_repo(dir.path());
9133
9134 let svc = WorktreesService::new();
9135 svc.handle(
9136 "register",
9137 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9138 )
9139 .await
9140 .unwrap();
9141 let payload = svc.handle("list", Value::Null).await.unwrap();
9142 let windows = windows_of(&payload);
9143 assert_eq!(
9145 windows[0].get("branch").and_then(Value::as_str),
9146 Some("main")
9147 );
9148 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9149 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9150 }
9151
9152 #[test]
9153 fn window_label_includes_sync_for_tracking_branch() {
9154 let dir = tempfile::tempdir().unwrap();
9155 let _repo = diverging_repo(dir.path());
9156 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9157 let entry = WindowEntry {
9158 key: "k".to_string(),
9159 folders: vec![dir.path().to_path_buf()],
9160 repo: Some("companion-repo".to_string()),
9161 title: None,
9162 pid: None,
9163 last_seen: Utc::now(),
9164 };
9165 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9167 }
9168
9169 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9173 let commit = repo.find_commit(base).unwrap();
9174 repo.branch(branch, &commit, false).unwrap();
9175 let reference = repo
9176 .find_reference(&format!("refs/heads/{branch}"))
9177 .unwrap();
9178 let mut opts = git2::WorktreeAddOptions::new();
9179 opts.reference(Some(&reference));
9180 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9181 }
9182
9183 #[test]
9184 fn git_status_marks_linked_worktree_and_names_parent_repo() {
9185 let main_dir = tempfile::tempdir().unwrap();
9186 let repo = init_repo(main_dir.path());
9187 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9188 repo.set_head("refs/heads/main").unwrap();
9189
9190 let wt_parent = tempfile::tempdir().unwrap();
9193 let wt_path = wt_parent.path().join("feature-wt");
9194 add_worktree(&repo, a, &wt_path, "feature");
9195
9196 let status = git_status(&wt_path);
9197 assert!(status.is_worktree);
9198 assert_eq!(status.branch.as_deref(), Some("feature"));
9199 assert_eq!(
9201 status.main_repo.as_deref(),
9202 main_dir.path().file_name().and_then(|n| n.to_str())
9203 );
9204
9205 let main_status = git_status(main_dir.path());
9207 assert!(!main_status.is_worktree);
9208 assert_eq!(main_status.main_repo, status.main_repo);
9209 }
9210
9211 #[test]
9212 fn window_label_marks_worktree_with_fork_glyph() {
9213 let main_dir = tempfile::tempdir().unwrap();
9214 let repo = init_repo(main_dir.path());
9215 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9216 repo.set_head("refs/heads/main").unwrap();
9217 let wt_parent = tempfile::tempdir().unwrap();
9218 let wt_path = wt_parent.path().join("feature-wt");
9219 add_worktree(&repo, a, &wt_path, "feature");
9220
9221 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9222 let entry = WindowEntry {
9223 key: "k".to_string(),
9224 folders: vec![wt_path],
9225 repo: Some("feature-wt".to_string()),
9226 title: None,
9227 pid: None,
9228 last_seen: Utc::now(),
9229 };
9230 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9233 }
9234
9235 #[test]
9236 fn main_repo_name_derives_from_common_dir() {
9237 assert_eq!(
9239 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9240 Some("omni-dev")
9241 );
9242 assert_eq!(
9244 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9245 Some("omni-dev")
9246 );
9247 assert_eq!(
9249 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9250 Some("omni-dev")
9251 );
9252 assert_eq!(main_repo_name(Path::new("/.git")), None);
9254 }
9255
9256 fn repos_of(payload: &Value) -> Vec<Value> {
9261 payload
9262 .get("repos")
9263 .and_then(Value::as_array)
9264 .expect("repos array")
9265 .clone()
9266 }
9267
9268 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9269 Some(GithubIdentity {
9270 owner: owner.to_string(),
9271 name: name.to_string(),
9272 })
9273 }
9274
9275 #[test]
9276 fn github_identity_parses_supported_forms() {
9277 assert_eq!(
9279 github_identity("https://github.com/rust-works/omni-dev.git"),
9280 github("rust-works", "omni-dev")
9281 );
9282 assert_eq!(
9283 github_identity("https://github.com/rust-works/omni-dev"),
9284 github("rust-works", "omni-dev")
9285 );
9286 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9287 assert_eq!(
9289 github_identity("git@github.com:rust-works/omni-dev.git"),
9290 github("rust-works", "omni-dev")
9291 );
9292 assert_eq!(
9293 github_identity("ssh://git@github.com/o/r.git"),
9294 github("o", "r")
9295 );
9296 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9297 assert_eq!(
9299 github_identity(" https://github.com/o/r/ "),
9300 github("o", "r")
9301 );
9302 }
9303
9304 #[test]
9305 fn github_identity_rejects_non_github_and_malformed() {
9306 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9308 assert_eq!(github_identity("git@example.com:o/r.git"), None);
9309 assert_eq!(github_identity("https://github.com/onlyowner"), None);
9311 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9312 assert_eq!(github_identity("https://github.com/"), None);
9313 assert_eq!(github_identity("not a url"), None);
9315 }
9316
9317 #[test]
9318 fn remote_github_identity_reads_origin_then_falls_back() {
9319 let dir = tempfile::tempdir().unwrap();
9320 let repo = init_repo(dir.path());
9321 assert_eq!(remote_github_identity(&repo), None);
9323 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9325 assert_eq!(remote_github_identity(&repo), None);
9326 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9328 .unwrap();
9329 assert_eq!(
9330 remote_github_identity(&repo),
9331 github("rust-works", "omni-dev")
9332 );
9333
9334 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9337 .unwrap();
9338 repo.remote("upstream", "https://github.com/other/proj.git")
9339 .unwrap();
9340 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9341 }
9342
9343 #[tokio::test]
9344 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9345 let svc = WorktreesService::new();
9346 assert_eq!(
9348 svc.handle("tree", Value::Null).await.unwrap(),
9349 json!({ "repos": [], "show_closed": true })
9350 );
9351 let plain = tempfile::tempdir().unwrap();
9353 svc.handle(
9354 "register",
9355 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9356 )
9357 .await
9358 .unwrap();
9359 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9360 }
9361
9362 #[tokio::test]
9363 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9364 let main_dir = tempfile::tempdir().unwrap();
9365 let repo = init_repo(main_dir.path());
9366 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9367 repo.set_head("refs/heads/main").unwrap();
9368 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9370 .unwrap();
9371
9372 let wt_parent = tempfile::tempdir().unwrap();
9375 let wt_path = wt_parent.path().join("feature-wt");
9376 add_worktree(&repo, a, &wt_path, "feature");
9377
9378 let svc = WorktreesService::new();
9379 svc.handle(
9382 "register",
9383 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9384 )
9385 .await
9386 .unwrap();
9387 svc.handle(
9388 "register",
9389 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9390 )
9391 .await
9392 .unwrap();
9393
9394 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9395 assert_eq!(
9396 repos.len(),
9397 1,
9398 "two worktrees of one repo dedupe: {repos:?}"
9399 );
9400 let repo0 = &repos[0];
9401 assert_eq!(
9403 repo0.get("main_repo").and_then(Value::as_str),
9404 main_dir.path().file_name().and_then(|n| n.to_str())
9405 );
9406 assert_eq!(
9407 repo0.pointer("/github/owner").and_then(Value::as_str),
9408 Some("rust-works")
9409 );
9410 assert_eq!(
9411 repo0.pointer("/github/name").and_then(Value::as_str),
9412 Some("omni-dev")
9413 );
9414 assert!(repo0.get("root").and_then(Value::as_str).is_some());
9415
9416 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9417 assert_eq!(worktrees.len(), 2);
9418 let main_wt = &worktrees[0];
9420 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9421 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9422 assert_eq!(
9423 main_wt.get("window_key").and_then(Value::as_str),
9424 Some("wm")
9425 );
9426 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9427 let linked = &worktrees[1];
9429 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9430 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9431 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9432 assert_eq!(
9433 linked.get("branch").and_then(Value::as_str),
9434 Some("feature")
9435 );
9436 }
9437
9438 #[tokio::test]
9439 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9440 let main_dir = tempfile::tempdir().unwrap();
9441 let repo = init_repo(main_dir.path());
9442 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9443 repo.set_head("refs/heads/main").unwrap();
9444 let wt_parent = tempfile::tempdir().unwrap();
9446 let wt_path = wt_parent.path().join("feature-wt");
9447 add_worktree(&repo, a, &wt_path, "feature");
9448
9449 let svc = WorktreesService::new();
9450 svc.handle(
9452 "register",
9453 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9454 )
9455 .await
9456 .unwrap();
9457
9458 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9459 assert_eq!(repos.len(), 1);
9460 assert!(repos[0].get("github").is_none(), "no remote → no github");
9461 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9462 let linked = worktrees
9463 .iter()
9464 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9465 .expect("the linked worktree");
9466 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9468 assert!(linked.get("window_key").is_none());
9469 }
9470
9471 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9477 let main_dir = tempfile::tempdir().unwrap();
9478 let repo = init_repo(main_dir.path());
9479 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9480 repo.set_head("refs/heads/trunk").unwrap();
9481 let wt_parent = tempfile::tempdir().unwrap();
9482 let wt_path = wt_parent.path().join("feature-wt");
9483 add_worktree(&repo, a, &wt_path, "feature");
9484 (main_dir, wt_parent, wt_path)
9485 }
9486
9487 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9491 {
9492 let main_dir = tempfile::tempdir().unwrap();
9493 let repo = init_repo(main_dir.path());
9494 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9495 repo.set_head("refs/heads/trunk").unwrap();
9496 let wt_parent = tempfile::tempdir().unwrap();
9497 let first = wt_parent.path().join("first-wt");
9498 let second = wt_parent.path().join("second-wt");
9499 add_worktree(&repo, a, &first, "first");
9500 add_worktree(&repo, a, &second, "second");
9501 (main_dir, wt_parent, first, second)
9502 }
9503
9504 #[tokio::test]
9505 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9506 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9507 let svc = Arc::new(WorktreesService::new());
9508
9509 let close = |path: PathBuf| {
9519 let svc = svc.clone();
9520 async move {
9521 svc.handle(
9522 "close",
9523 json!({ "path": path, "remove": true, "confirmed": true }),
9524 )
9525 .await
9526 }
9527 };
9528 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9529
9530 assert_eq!(a.unwrap(), json!({ "removed": true }));
9531 assert_eq!(b.unwrap(), json!({ "removed": true }));
9532 assert!(!first.exists());
9533 assert!(!second.exists());
9534 let repo = Repository::open(main_dir.path()).unwrap();
9537 assert!(repo.worktrees().unwrap().is_empty());
9538 }
9539
9540 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9547 let repo = init_repo(dir);
9548 let refname = format!("refs/heads/{branch}");
9549 let head = empty_commit(&repo, Some(&refname), &[], "A");
9550 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9551 .unwrap();
9552 repo.set_head(&refname).unwrap();
9553 let mut cfg = repo.config().unwrap();
9554 cfg.set_str("remote.origin.url", url).unwrap();
9555 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9556 .unwrap();
9557 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9558 .unwrap();
9559 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9560 .unwrap();
9561 repo
9562 }
9563
9564 #[test]
9565 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9566 let dir = tempfile::tempdir().unwrap();
9567 let _repo = pushed_github_repo(
9568 dir.path(),
9569 "https://github.com/rust-works/omni-dev.git",
9570 "feature",
9571 );
9572 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9573 assert_eq!(
9574 ok.target,
9575 PrTarget {
9576 owner: "rust-works".into(),
9577 name: "omni-dev".into(),
9578 branch: "feature".into(),
9579 }
9580 );
9581 assert!(!ok.head_sha.is_empty());
9582 }
9583
9584 #[test]
9585 fn evaluate_local_skips_an_unborn_head() {
9586 let dir = tempfile::tempdir().unwrap();
9587 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9589 }
9590
9591 #[test]
9592 fn evaluate_local_skips_a_branch_with_no_upstream() {
9593 let dir = tempfile::tempdir().unwrap();
9594 let repo = init_repo(dir.path());
9595 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9596 repo.set_head("refs/heads/main").unwrap();
9597 repo.config()
9599 .unwrap()
9600 .set_str("remote.origin.url", "https://github.com/o/r.git")
9601 .unwrap();
9602 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9603 }
9604
9605 #[test]
9606 fn evaluate_local_skips_unpushed_local_commits() {
9607 let dir = tempfile::tempdir().unwrap();
9608 let repo = init_repo(dir.path());
9609 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9610 let a_commit = repo.find_commit(a).unwrap();
9611 repo.reference("refs/remotes/origin/main", a, true, "o")
9613 .unwrap();
9614 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9615 drop(a_commit);
9616 repo.set_head("refs/heads/main").unwrap();
9617 let mut cfg = repo.config().unwrap();
9618 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9619 .unwrap();
9620 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9623 .unwrap();
9624 cfg.set_str("branch.main.remote", "origin").unwrap();
9625 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9626 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9627 }
9628
9629 #[test]
9630 fn evaluate_local_skips_a_detached_head() {
9631 let dir = tempfile::tempdir().unwrap();
9632 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9633 let head = repo.head().unwrap().target().unwrap();
9634 repo.set_head_detached(head).unwrap();
9635 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9636 }
9637
9638 #[test]
9639 fn evaluate_local_skips_a_non_github_remote() {
9640 let dir = tempfile::tempdir().unwrap();
9641 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9642 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9643 }
9644
9645 #[test]
9646 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9647 assert_eq!(
9651 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9652 .unwrap_err()
9653 .kind,
9654 "not-a-repo"
9655 );
9656 }
9657
9658 #[test]
9659 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9660 let req = MergeQueueRequest {
9663 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9664 requester_key: Some("win-9".into()),
9665 check: true,
9666 confirmed: false,
9667 };
9668 let logs = capture_info(|| log_merge_check(&req, 1, 1));
9669 assert!(logs.contains("merge-queue check"), "{logs}");
9670 assert!(logs.contains("win-9"), "{logs}");
9671 assert!(logs.contains("requested=2"), "{logs}");
9672 assert!(logs.contains("eligible=1"), "{logs}");
9673 }
9674
9675 #[test]
9676 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9677 let req = MergeQueueRequest {
9679 paths: vec![PathBuf::from("/a")],
9680 requester_key: None,
9681 check: false,
9682 confirmed: true,
9683 };
9684 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9685 assert!(logs.contains("merge-queue enqueue"), "{logs}");
9686 assert!(logs.contains("queued=2"), "{logs}");
9687 assert!(logs.contains("failed=1"), "{logs}");
9688 }
9689
9690 #[test]
9691 fn evaluate_local_flags_dirty_then_untracked() {
9692 let main_dir = tempfile::tempdir().unwrap();
9694 let repo = init_repo(main_dir.path());
9695 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9696 repo.set_head("refs/heads/main").unwrap();
9697 let wt_parent = tempfile::tempdir().unwrap();
9698 let wt_path = wt_parent.path().join("feature-wt");
9699 add_worktree(&repo, a, &wt_path, "feature");
9700
9701 let clean = evaluate_local(&wt_path).unwrap_err();
9703 assert_ne!(clean.kind, "dirty");
9704 assert_ne!(clean.kind, "untracked");
9705
9706 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9708 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9709
9710 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9712 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9713 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9714 }
9715
9716 #[test]
9717 fn is_conflicting_blocks_only_dirty_and_conflicting() {
9718 assert!(is_conflicting(Some("CONFLICTING")));
9719 assert!(is_conflicting(Some("DIRTY")));
9720 assert!(!is_conflicting(Some("CLEAN")));
9721 assert!(!is_conflicting(Some("BLOCKED")));
9722 assert!(!is_conflicting(Some("UNKNOWN")));
9723 assert!(!is_conflicting(None));
9724 }
9725
9726 #[test]
9727 fn merge_queue_request_parses_batch_and_phase_flags() {
9728 let req: MergeQueueRequest = serde_json::from_value(json!({
9729 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9730 }))
9731 .unwrap();
9732 assert_eq!(req.paths.len(), 2);
9733 assert_eq!(req.requester_key.as_deref(), Some("w1"));
9734 assert!(req.confirmed);
9735 assert!(!req.check);
9736 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9738 assert!(req.paths.is_empty());
9739 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9740 }
9741
9742 #[test]
9743 fn queued_pr_omits_already_queued_when_false() {
9744 let v = serde_json::to_value(QueuedPr {
9745 path: "/a".into(),
9746 number: 5,
9747 already_queued: false,
9748 })
9749 .unwrap();
9750 assert!(v.get("already_queued").is_none(), "{v}");
9751 let v = serde_json::to_value(QueuedPr {
9752 path: "/a".into(),
9753 number: 5,
9754 already_queued: true,
9755 })
9756 .unwrap();
9757 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9758 }
9759
9760 #[tokio::test]
9761 async fn merge_queue_check_on_empty_selection_reports_nothing() {
9762 let svc = WorktreesService::new();
9763 let reply = svc
9764 .handle("merge-queue", json!({ "paths": [], "check": true }))
9765 .await
9766 .unwrap();
9767 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9768 }
9769
9770 #[tokio::test]
9771 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9772 let dir = tempfile::tempdir().unwrap();
9775 let _repo = init_repo(dir.path());
9776 let svc = WorktreesService::new();
9777 let reply = svc
9778 .handle(
9779 "merge-queue",
9780 json!({ "paths": [dir.path()], "check": true }),
9781 )
9782 .await
9783 .unwrap();
9784 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9785 assert_eq!(skipped.len(), 1);
9786 assert_eq!(
9787 skipped[0].get("kind").and_then(Value::as_str),
9788 Some("no-commits")
9789 );
9790 assert!(reply
9791 .get("eligible")
9792 .and_then(Value::as_array)
9793 .unwrap()
9794 .is_empty());
9795 }
9796
9797 fn ready_worktree() -> (tempfile::TempDir, String) {
9801 let dir = tempfile::tempdir().unwrap();
9802 let repo = pushed_github_repo(
9803 dir.path(),
9804 "https://github.com/rust-works/omni-dev.git",
9805 "feature",
9806 );
9807 let head = repo.head().unwrap().target().unwrap().to_string();
9808 (dir, head)
9809 }
9810
9811 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9814 format!(
9815 r#"{{"data":{{"r0":{{"b0":{{
9816 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9817 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9818 ]}}}}}},
9819 "associatedPullRequests":{{"nodes":[{pr}]}}
9820 }}}}}}}}"#
9821 )
9822 }
9823
9824 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9828 let ghdir = tempfile::tempdir().unwrap();
9829 let (bin, _shim) = fake_gh(ghdir.path(), reply);
9830 let paths = vec![head_dir.to_path_buf()];
9831 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9832 if let Some(e) = eligible.first() {
9833 return Ok(e.number);
9834 }
9835 Err(skipped.remove(0).kind)
9836 }
9837
9838 #[test]
9839 fn evaluate_batch_marks_a_ready_pr_eligible() {
9840 let (dir, head) = ready_worktree();
9841 let pr = format!(
9842 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9843 );
9844 assert_eq!(
9845 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9846 Ok(9)
9847 );
9848 }
9849
9850 #[test]
9851 fn evaluate_batch_skips_a_draft_pr() {
9852 let (dir, head) = ready_worktree();
9853 let pr = format!(
9854 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9855 );
9856 assert_eq!(
9857 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9858 Err("draft".to_string())
9859 );
9860 }
9861
9862 #[test]
9863 fn evaluate_batch_skips_a_conflicting_pr() {
9864 let (dir, head) = ready_worktree();
9865 let pr = format!(
9866 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9867 );
9868 assert_eq!(
9869 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9870 Err("conflicting".to_string())
9871 );
9872 }
9873
9874 #[test]
9875 fn evaluate_batch_skips_a_pr_with_failing_checks() {
9876 let (dir, head) = ready_worktree();
9877 let pr = format!(
9878 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9879 );
9880 assert_eq!(
9881 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9882 Err("checks-failing".to_string())
9883 );
9884 }
9885
9886 #[test]
9887 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9888 let (dir, head) = ready_worktree();
9889 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9891 assert_eq!(
9892 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9893 Err("stale".to_string())
9894 );
9895 }
9896
9897 #[test]
9898 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9899 let (dir, head) = ready_worktree();
9900 let reply = format!(
9902 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9903 );
9904 assert_eq!(
9905 network_gate_outcome(dir.path(), &reply),
9906 Err("no-pr".to_string())
9907 );
9908 }
9909
9910 #[test]
9911 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9912 let eligible = vec![
9915 Eligible {
9916 path: PathBuf::from("/wt/a"),
9917 number: 1,
9918 url: "u".into(),
9919 branch: "a".into(),
9920 pr_id: "PR_A".into(),
9921 already_queued: true,
9922 },
9923 Eligible {
9924 path: PathBuf::from("/wt/b"),
9925 number: 2,
9926 url: "u".into(),
9927 branch: "b".into(),
9928 pr_id: "PR_B".into(),
9929 already_queued: false,
9930 },
9931 ];
9932 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9933 assert_eq!(queued.len(), 1);
9934 assert_eq!(queued[0].number, 1);
9935 assert!(queued[0].already_queued);
9936 assert_eq!(failed.len(), 1);
9937 assert_eq!(failed[0].number, 2);
9938 }
9939
9940 #[test]
9941 fn enqueue_eligible_records_a_github_rejection_as_failed() {
9942 let ghdir = tempfile::tempdir().unwrap();
9946 let (bin, _shim) = fake_gh(
9947 ghdir.path(),
9948 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9949 );
9950 let eligible = vec![Eligible {
9951 path: PathBuf::from("/wt/a"),
9952 number: 7,
9953 url: "u".into(),
9954 branch: "a".into(),
9955 pr_id: "PR_A".into(),
9956 already_queued: false,
9957 }];
9958 let (queued, failed) = enqueue_eligible(&bin, eligible);
9959 assert!(queued.is_empty(), "{queued:?}");
9960 assert_eq!(failed.len(), 1);
9961 assert_eq!(failed[0].number, 7);
9962 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9965 }
9966
9967 #[tokio::test]
9968 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9970 let (dir, head) = ready_worktree();
9973 let pr = format!(
9974 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9975 );
9976 let ghdir = tempfile::tempdir().unwrap();
9977 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9978 let svc = WorktreesService::new();
9979 let req = MergeQueueRequest {
9980 paths: vec![dir.path().to_path_buf()],
9981 requester_key: None,
9982 check: true,
9983 confirmed: false,
9984 };
9985 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9989 .await
9990 .unwrap();
9991 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9992 assert_eq!(eligible.len(), 1);
9993 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9994 assert_eq!(
9995 eligible[0].get("branch").and_then(Value::as_str),
9996 Some("feature")
9997 );
9998 }
9999
10000 #[tokio::test]
10001 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
10003 let (dir, head) = ready_worktree();
10006 let pr = format!(
10007 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10008 );
10009 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
10010 let ghdir = tempfile::tempdir().unwrap();
10011 let guard = shim_lock();
10012 let bin = ghdir.path().join("fake-gh");
10013 write_exec_script(
10014 &bin,
10015 &format!(
10016 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
10017 enqueue =
10018 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
10019 ),
10020 );
10021 let svc = WorktreesService::new();
10022 let req = MergeQueueRequest {
10023 paths: vec![dir.path().to_path_buf()],
10024 requester_key: Some("w1".into()),
10025 check: false,
10026 confirmed: true,
10027 };
10028 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10033 .await
10034 .unwrap();
10035 drop(guard);
10036 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
10037 assert_eq!(queued.len(), 1, "{reply}");
10038 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
10039 assert!(reply
10040 .get("failed")
10041 .and_then(Value::as_array)
10042 .unwrap()
10043 .is_empty());
10044 }
10045
10046 #[tokio::test]
10047 async fn concurrent_closes_overlap_their_heartbeat_waits() {
10048 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
10049 let svc = Arc::new(WorktreesService::new());
10050 for (key, path) in [("w2", &first), ("w3", &second)] {
10052 svc.handle("register", json!({ "key": key, "folders": [path] }))
10053 .await
10054 .unwrap();
10055 }
10056
10057 let spawn_close = |path: PathBuf| {
10058 let svc = svc.clone();
10059 tokio::spawn(async move {
10060 svc.handle(
10061 "close",
10062 json!({
10063 "path": path,
10064 "remove": true,
10065 "confirmed": true,
10066 "requester_key": "w1",
10067 }),
10068 )
10069 .await
10070 })
10071 };
10072 let a = spawn_close(first.clone());
10073 let b = spawn_close(second.clone());
10074
10075 for key in ["w2", "w3"] {
10082 let mut saw_close = false;
10083 for _ in 0..400 {
10084 let hb = svc
10085 .handle("heartbeat", json!({ "key": key }))
10086 .await
10087 .unwrap();
10088 if hb.get("close").and_then(Value::as_bool) == Some(true) {
10089 saw_close = true;
10090 break;
10091 }
10092 tokio::time::sleep(Duration::from_millis(5)).await;
10093 }
10094 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10095 }
10096 assert!(
10097 !a.is_finished() && !b.is_finished(),
10098 "neither close can have finished: both windows are still registered"
10099 );
10100
10101 for key in ["w2", "w3"] {
10103 svc.handle("unregister", json!({ "key": key }))
10104 .await
10105 .unwrap();
10106 }
10107 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10108 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10109 assert!(!first.exists());
10110 assert!(!second.exists());
10111 }
10112
10113 #[tokio::test]
10114 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10115 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10116 let svc = WorktreesService::new();
10117 let report = svc
10120 .handle("close", json!({ "path": wt_path, "remove": true }))
10121 .await
10122 .unwrap();
10123 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10124 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10125 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10126 assert!(report
10127 .get("risks")
10128 .and_then(Value::as_array)
10129 .unwrap()
10130 .is_empty());
10131 assert!(wt_path.exists());
10133 }
10134
10135 #[tokio::test]
10136 async fn close_removes_a_clean_linked_worktree() {
10137 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10138 let svc = WorktreesService::new();
10139 let reply = svc
10140 .handle(
10141 "close",
10142 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10143 )
10144 .await
10145 .unwrap();
10146 assert_eq!(reply, json!({ "removed": true }));
10147 assert!(
10148 !wt_path.exists(),
10149 "the worktree directory should be deleted"
10150 );
10151 }
10152
10153 fn capture_info(f: impl FnOnce()) -> String {
10165 crate::test_support::capture_at(tracing::Level::INFO, f)
10166 }
10167
10168 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10172 RebaseRequest {
10173 paths,
10174 requester_key: None,
10175 check: false,
10176 confirmed: false,
10177 keep_conflicts: false,
10178 autostash: false,
10179 onto: None,
10180 }
10181 }
10182
10183 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10191 let main_dir = tempfile::tempdir().unwrap();
10192 let repo = init_repo(main_dir.path());
10193 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10194 repo.set_head("refs/heads/main").unwrap();
10195 let wt_parent = tempfile::tempdir().unwrap();
10196 let wt_path = wt_parent.path().join("feature-wt");
10197 add_worktree(&repo, base, &wt_path, "feature");
10198 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10200 (main_dir, wt_parent, wt_path)
10201 }
10202
10203 #[tokio::test]
10204 async fn rebase_with_refuses_an_empty_selection() {
10205 let svc = WorktreesService::new();
10207 let err = svc
10208 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10209 .await
10210 .unwrap_err()
10211 .to_string();
10212 assert!(err.contains("at least one path"), "{err}");
10213 }
10214
10215 #[tokio::test]
10216 async fn rebase_with_phase_one_reports_without_rebasing() {
10217 let (_main, _parent, wt) = behind_worktree();
10218 let before = Repository::open(&wt).unwrap().head().unwrap().target();
10219
10220 let svc = WorktreesService::new();
10221 let reply = svc
10222 .rebase_with(
10223 RebaseRequest {
10224 check: true,
10225 onto: Some("main".into()),
10226 ..rebase_req(vec![wt.clone()])
10227 },
10228 crate::git::resolve_git_binary(),
10229 )
10230 .await
10231 .unwrap();
10232
10233 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10234 assert_eq!(worktrees.len(), 1, "{reply}");
10235 assert_eq!(
10236 worktrees[0].get("status").and_then(Value::as_str),
10237 Some("would-rebase"),
10238 "{reply}"
10239 );
10240 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10242 assert_eq!(fetches.len(), 1);
10243 assert_eq!(
10244 fetches[0].get("fetched").and_then(Value::as_bool),
10245 Some(false)
10246 );
10247 assert_eq!(
10248 Repository::open(&wt).unwrap().head().unwrap().target(),
10249 before,
10250 "phase 1 must not move the branch"
10251 );
10252 }
10253
10254 #[tokio::test]
10255 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10256 let (_main, _parent, wt) = behind_worktree();
10257 let svc = WorktreesService::new();
10258 let reply = svc
10259 .rebase_with(
10260 RebaseRequest {
10261 confirmed: true,
10262 onto: Some("main".into()),
10263 ..rebase_req(vec![wt.clone()])
10264 },
10265 crate::git::resolve_git_binary(),
10266 )
10267 .await
10268 .unwrap();
10269
10270 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10271 assert_eq!(
10272 worktrees[0].get("status").and_then(Value::as_str),
10273 Some("rebased"),
10274 "{reply}"
10275 );
10276 assert!(
10278 svc.registry.rebasing_paths().is_empty(),
10279 "the rebasing mark must be cleared after the execute"
10280 );
10281 }
10282
10283 #[tokio::test]
10284 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10285 let (_main, _parent, wt) = behind_worktree();
10289 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10290
10291 let svc = WorktreesService::new();
10292 let reply = svc
10293 .rebase_with(
10294 RebaseRequest {
10295 confirmed: true,
10296 onto: Some("main".into()),
10297 ..rebase_req(vec![wt.clone()])
10298 },
10299 crate::git::resolve_git_binary(),
10300 )
10301 .await
10302 .unwrap();
10303
10304 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10305 assert_eq!(
10306 worktrees[0].get("status").and_then(Value::as_str),
10307 Some("skipped"),
10308 "{reply}"
10309 );
10310 assert_eq!(
10311 worktrees[0].get("reason").and_then(Value::as_str),
10312 Some("dirty"),
10313 "{reply}"
10314 );
10315 }
10316
10317 #[tokio::test]
10318 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10319 let (_main, _parent, wt) = behind_worktree();
10325 std::fs::create_dir_all(wt.join(".git")).ok();
10328 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10329 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10330 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10331 assert_ne!(
10332 Repository::open(&wt).unwrap().state(),
10333 RepositoryState::Clean,
10334 "precondition: the worktree looks mid-rebase to git2"
10335 );
10336
10337 let svc = WorktreesService::new();
10338 let reply = svc
10339 .rebase_with(
10340 RebaseRequest {
10341 confirmed: true,
10342 onto: Some("main".into()),
10343 ..rebase_req(vec![wt.clone()])
10344 },
10345 crate::git::resolve_git_binary(),
10346 )
10347 .await
10348 .unwrap();
10349
10350 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10351 assert_eq!(
10352 worktrees[0].get("reason").and_then(Value::as_str),
10353 Some("operation-in-progress"),
10354 "{reply}"
10355 );
10356 assert_ne!(
10358 Repository::open(&wt).unwrap().state(),
10359 RepositoryState::Clean
10360 );
10361 }
10362
10363 #[test]
10364 fn rebase_request_maps_onto_engine_options() {
10365 let req = RebaseRequest {
10366 keep_conflicts: true,
10367 autostash: true,
10368 onto: Some("origin/release".into()),
10369 ..rebase_req(vec![PathBuf::from("/wt")])
10370 };
10371 let opts = req.options(PathBuf::from("/custom/git"));
10372 assert!(opts.keep_conflicts && opts.autostash);
10373 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10374 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10375 assert!(!opts.dry_run);
10379 }
10380
10381 #[test]
10382 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10383 let req = RebaseRequest {
10384 requester_key: Some("win-3".into()),
10385 check: true,
10386 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10387 };
10388 let plan = worktree_rebase::Plan {
10389 fetches: vec![worktree_rebase::FetchOutcome {
10390 repo_root: PathBuf::from("/repo"),
10391 onto: "origin/main".into(),
10392 fetched: true,
10393 ok: false,
10394 detail: Some("host unreachable".into()),
10395 }],
10396 worktrees: vec![
10397 worktree_rebase::WorktreeOutcome {
10398 path: PathBuf::from("/a"),
10399 branch: Some("a".into()),
10400 onto: "origin/main".into(),
10401 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10402 },
10403 worktree_rebase::WorktreeOutcome {
10404 path: PathBuf::from("/b"),
10405 branch: Some("b".into()),
10406 onto: "origin/main".into(),
10407 result: worktree_rebase::RebaseResult::UpToDate,
10408 },
10409 ],
10410 };
10411 let logs = capture_info(|| log_rebase_check(&req, &plan));
10412 assert!(logs.contains("rebase check"), "{logs}");
10413 assert!(logs.contains("win-3"), "{logs}");
10414 assert!(logs.contains("requested=2"), "{logs}");
10415 assert!(logs.contains("pending=1"), "{logs}");
10416 assert!(logs.contains("failed_fetches=1"), "{logs}");
10417 }
10418
10419 #[test]
10420 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10421 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10423 let outcome = |result| worktree_rebase::WorktreeOutcome {
10424 path: PathBuf::from("/x"),
10425 branch: Some("x".into()),
10426 onto: "origin/main".into(),
10427 result,
10428 };
10429 let outcomes = vec![
10430 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10431 outcome(worktree_rebase::RebaseResult::Conflict {
10432 detail: "CONFLICT".into(),
10433 left_in_place: true,
10434 }),
10435 outcome(worktree_rebase::RebaseResult::Skipped {
10436 reason: worktree_rebase::SkipReason::Dirty,
10437 }),
10438 ];
10439 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10440 assert!(logs.contains("rebase execute"), "{logs}");
10441 assert!(logs.contains("rebased=1"), "{logs}");
10442 assert!(logs.contains("conflicts=1"), "{logs}");
10443 assert!(logs.contains("left_in_place=1"), "{logs}");
10444 assert!(logs.contains("skipped=1"), "{logs}");
10445 assert!(logs.contains(r#"requester="-""#), "{logs}");
10446 }
10447
10448 fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10452 PushRequest {
10453 paths,
10454 requester_key: None,
10455 check: false,
10456 confirmed: false,
10457 }
10458 }
10459
10460 fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10467 let _guard = crate::git::worktree_batch::test_serial_lock();
10470 let root = tempfile::tempdir().unwrap();
10471 let origin = root.path().join("origin.git");
10472 let local = root.path().join("local");
10473 let wt = root.path().join("feature-wt");
10474 std::fs::create_dir_all(&origin).unwrap();
10475 std::fs::create_dir_all(&local).unwrap();
10476
10477 let git = |dir: &Path, args: &[&str]| {
10478 let out = crate::git::worktree_batch::run_git_in(
10479 &crate::git::resolve_git_binary(),
10480 dir,
10481 args,
10482 )
10483 .unwrap();
10484 assert!(
10485 out.status.success(),
10486 "git {args:?} failed: {}",
10487 String::from_utf8_lossy(&out.stderr)
10488 );
10489 };
10490
10491 git(&origin, &["init", "--bare", "-b", "main"]);
10492 git(&local, &["init", "-b", "main"]);
10493 git(&local, &["config", "user.name", "Test"]);
10494 git(&local, &["config", "user.email", "test@example.com"]);
10495 git(&local, &["config", "commit.gpgsign", "false"]);
10496 std::fs::write(local.join("f.txt"), "base\n").unwrap();
10497 git(&local, &["add", "f.txt"]);
10498 git(&local, &["commit", "-m", "base"]);
10499 git(
10500 &local,
10501 &["remote", "add", "origin", origin.to_str().unwrap()],
10502 );
10503 git(&local, &["push", "-u", "origin", "main"]);
10504 git(
10505 &local,
10506 &[
10507 "worktree",
10508 "add",
10509 "-b",
10510 "feature",
10511 wt.to_str().unwrap(),
10512 "main",
10513 ],
10514 );
10515 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10516 git(&wt, &["add", "g.txt"]);
10517 git(&wt, &["commit", "-m", "work"]);
10518 git(&wt, &["push", "-u", "origin", "feature"]);
10519 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10521
10522 (root, origin, std::fs::canonicalize(&wt).unwrap())
10523 }
10524
10525 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10527 Repository::open_bare(origin)
10528 .unwrap()
10529 .refname_to_id(refname)
10530 .ok()
10531 }
10532
10533 #[tokio::test]
10534 async fn push_with_refuses_an_empty_selection() {
10535 let svc = WorktreesService::new();
10537 let err = svc
10538 .push_with(push_req(Vec::new()), PathBuf::from("git"))
10539 .await
10540 .unwrap_err()
10541 .to_string();
10542 assert!(err.contains("at least one path"), "{err}");
10543 }
10544
10545 #[tokio::test]
10546 async fn push_with_phase_one_reports_without_publishing() {
10547 let (_root, origin, wt) = rewritten_worktree();
10548 let before = origin_tip(&origin, "refs/heads/feature");
10549
10550 let svc = WorktreesService::new();
10551 let reply = svc
10552 .push_with(
10553 PushRequest {
10554 check: true,
10555 ..push_req(vec![wt.clone()])
10556 },
10557 crate::git::resolve_git_binary(),
10558 )
10559 .await
10560 .unwrap();
10561
10562 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10563 assert_eq!(worktrees.len(), 1, "{reply}");
10564 assert_eq!(
10565 worktrees[0].get("status").and_then(Value::as_str),
10566 Some("would-force"),
10567 "{reply}"
10568 );
10569 assert!(
10570 reply.get("fetches").is_none(),
10571 "a push plan contacts no remote, so it reports no fetches: {reply}"
10572 );
10573 assert_eq!(
10574 origin_tip(&origin, "refs/heads/feature"),
10575 before,
10576 "phase 1 must publish nothing"
10577 );
10578 assert!(
10579 svc.registry.pushing_paths().is_empty(),
10580 "phase 1 must not mark a row as in flight"
10581 );
10582 }
10583
10584 #[tokio::test]
10585 async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10586 let (_root, origin, wt) = rewritten_worktree();
10587 let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10588
10589 let svc = WorktreesService::new();
10590 let reply = svc
10591 .push_with(
10592 PushRequest {
10593 confirmed: true,
10594 ..push_req(vec![wt.clone()])
10595 },
10596 crate::git::resolve_git_binary(),
10597 )
10598 .await
10599 .unwrap();
10600
10601 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10602 assert_eq!(
10603 worktrees[0].get("status").and_then(Value::as_str),
10604 Some("pushed"),
10605 "{reply}"
10606 );
10607 assert_eq!(
10608 worktrees[0].get("forced").and_then(Value::as_bool),
10609 Some(true),
10610 "a rewritten branch is published under the lease: {reply}"
10611 );
10612 assert_eq!(
10613 origin_tip(&origin, "refs/heads/feature"),
10614 rewritten,
10615 "the remote must carry the rewritten tip"
10616 );
10617 assert!(
10618 svc.registry.pushing_paths().is_empty(),
10619 "the pushing mark must be cleared after the execute — a push writes no \
10620 on-disk state, so nothing else could ever correct a leftover"
10621 );
10622 }
10623
10624 #[tokio::test]
10625 async fn push_resolves_the_git_binary_for_itself() {
10626 let (_root, origin, wt) = rewritten_worktree();
10630 let before = origin_tip(&origin, "refs/heads/feature");
10631
10632 let svc = WorktreesService::new();
10633 let reply = svc
10634 .push(PushRequest {
10635 check: true,
10636 ..push_req(vec![wt])
10637 })
10638 .await
10639 .unwrap();
10640
10641 assert_eq!(
10642 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10643 .get("status")
10644 .and_then(Value::as_str),
10645 Some("would-force"),
10646 "{reply}"
10647 );
10648 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10649 }
10650
10651 #[tokio::test]
10652 async fn push_with_defaults_to_report_only_without_confirmation() {
10653 let (_root, origin, wt) = rewritten_worktree();
10656 let before = origin_tip(&origin, "refs/heads/feature");
10657
10658 let svc = WorktreesService::new();
10659 let reply = svc
10660 .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10661 .await
10662 .unwrap();
10663
10664 assert_eq!(
10665 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10666 .get("status")
10667 .and_then(Value::as_str),
10668 Some("would-force"),
10669 "{reply}"
10670 );
10671 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10672 }
10673
10674 #[tokio::test]
10675 async fn push_with_refuses_to_force_the_remote_default_branch() {
10676 let (root, origin, _wt) = rewritten_worktree();
10679 let local = root.path().join("local");
10680 let before = origin_tip(&origin, "refs/heads/main");
10681 crate::git::worktree_batch::run_git_in(
10682 &crate::git::resolve_git_binary(),
10683 &local,
10684 &["commit", "--amend", "-m", "rewritten main"],
10685 )
10686 .unwrap();
10687
10688 let svc = WorktreesService::new();
10689 let reply = svc
10690 .push_with(
10691 PushRequest {
10692 confirmed: true,
10693 ..push_req(vec![local.clone()])
10694 },
10695 crate::git::resolve_git_binary(),
10696 )
10697 .await
10698 .unwrap();
10699
10700 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10701 assert_eq!(
10702 worktrees[0].get("reason").and_then(Value::as_str),
10703 Some("default-branch-force-push"),
10704 "{reply}"
10705 );
10706 assert_eq!(
10707 origin_tip(&origin, "refs/heads/main"),
10708 before,
10709 "the default branch's published history must be untouched"
10710 );
10711 }
10712
10713 #[test]
10714 fn log_push_check_separates_the_force_count_from_the_pending_count() {
10715 let req = PushRequest {
10716 requester_key: Some("win-7".into()),
10717 check: true,
10718 ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10719 };
10720 let outcome = |result| worktree_push::WorktreeOutcome {
10721 path: PathBuf::from("/x"),
10722 branch: Some("x".into()),
10723 remote: "origin".into(),
10724 remote_branch: "x".into(),
10725 result,
10726 };
10727 let plan = worktree_push::Plan {
10728 worktrees: vec![
10729 outcome(worktree_push::PushResult::WouldForce {
10730 ahead: 1,
10731 behind: 1,
10732 }),
10733 outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10734 outcome(worktree_push::PushResult::Skipped {
10735 reason: worktree_push::SkipReason::DefaultBranchForcePush,
10736 }),
10737 ],
10738 };
10739 let logs = capture_info(|| log_push_check(&req, &plan));
10740 assert!(logs.contains("push check"), "{logs}");
10741 assert!(logs.contains("pending=2"), "{logs}");
10742 assert!(logs.contains("forced=1"), "{logs}");
10743 assert!(logs.contains("skipped=1"), "{logs}");
10744 assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10745 }
10746
10747 #[test]
10748 fn log_push_execute_counts_lease_refusals_separately() {
10749 let req = push_req(vec![PathBuf::from("/a")]);
10750 let outcome = |result| worktree_push::WorktreeOutcome {
10751 path: PathBuf::from("/x"),
10752 branch: Some("x".into()),
10753 remote: "origin".into(),
10754 remote_branch: "x".into(),
10755 result,
10756 };
10757 let outcomes = vec![
10758 outcome(worktree_push::PushResult::Pushed { forced: true }),
10759 outcome(worktree_push::PushResult::Pushed { forced: false }),
10760 outcome(worktree_push::PushResult::Created),
10761 outcome(worktree_push::PushResult::Rejected {
10762 detail: "stale info".into(),
10763 stale: true,
10764 }),
10765 outcome(worktree_push::PushResult::Rejected {
10766 detail: "pre-receive hook declined".into(),
10767 stale: false,
10768 }),
10769 ];
10770 let logs = capture_info(|| log_push_execute(&req, &outcomes));
10771 assert!(logs.contains("push execute"), "{logs}");
10772 assert!(logs.contains("pushed=2"), "{logs}");
10773 assert!(logs.contains("forced=1"), "{logs}");
10774 assert!(logs.contains("created=1"), "{logs}");
10775 assert!(logs.contains("rejected=2"), "{logs}");
10776 assert!(
10777 logs.contains("stale_rejected=1"),
10778 "a lease refusal is the interesting half of a rejection: {logs}"
10779 );
10780 }
10781
10782 #[test]
10783 fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10784 let dir = tempfile::tempdir().unwrap();
10785 let path = canonical(dir.path());
10786
10787 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10788 assert!(!quiet.pushing);
10789 let json = serde_json::to_value(&quiet).unwrap();
10790 assert!(
10791 json.get("pushing").is_none(),
10792 "an idle row stays byte-identical for an older client: {json}"
10793 );
10794
10795 let busy = worktree_entry(
10796 &path,
10797 true,
10798 &HashMap::new(),
10799 &InFlight {
10800 pushing: [path.clone()].into(),
10801 rebasing: HashSet::new(),
10802 },
10803 );
10804 assert!(busy.pushing, "the registry's transient mark rides through");
10805 assert!(
10806 !busy.rebasing,
10807 "the two cues are independent — a push must not read as a rebase"
10808 );
10809 assert_eq!(
10810 serde_json::to_value(&busy).unwrap()["pushing"],
10811 serde_json::json!(true)
10812 );
10813 }
10814
10815 #[test]
10816 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10817 assert_eq!(operation_slug(RepositoryState::Clean), None);
10818 assert_eq!(
10819 operation_slug(RepositoryState::Rebase).as_deref(),
10820 Some("rebase")
10821 );
10822 assert_eq!(
10823 operation_slug(RepositoryState::RebaseMerge).as_deref(),
10824 Some("rebase"),
10825 "the merge-backend rebase is still just a rebase to the user"
10826 );
10827 assert_eq!(
10828 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10829 Some("rebase-interactive")
10830 );
10831 assert_eq!(
10832 operation_slug(RepositoryState::Merge).as_deref(),
10833 Some("merge")
10834 );
10835 assert_eq!(
10836 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10837 Some("cherry-pick")
10838 );
10839 assert_eq!(
10840 operation_slug(RepositoryState::RevertSequence).as_deref(),
10841 Some("revert")
10842 );
10843 assert_eq!(
10844 operation_slug(RepositoryState::Bisect).as_deref(),
10845 Some("bisect")
10846 );
10847 assert_eq!(
10848 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10849 Some("apply-mailbox")
10850 );
10851 }
10852
10853 #[test]
10854 fn git_status_omits_operation_for_a_clean_worktree() {
10855 let dir = tempfile::tempdir().unwrap();
10856 let _repo = diverging_repo(dir.path());
10857 assert_eq!(
10858 git_status(dir.path()).operation,
10859 None,
10860 "a clean worktree carries no operation, so the field stays off the wire"
10861 );
10862 }
10863
10864 #[test]
10865 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10866 let main_dir = tempfile::tempdir().unwrap();
10867 let repo = init_repo(main_dir.path());
10868 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10869 repo.set_head("refs/heads/main").unwrap();
10870 let path = canonical(main_dir.path());
10871
10872 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10873 assert!(!quiet.rebasing);
10874 let json = serde_json::to_value(&quiet).unwrap();
10876 assert!(json.get("rebasing").is_none(), "{json}");
10877 assert!(json.get("operation").is_none(), "{json}");
10878
10879 let busy = worktree_entry(
10880 &path,
10881 true,
10882 &HashMap::new(),
10883 &InFlight {
10884 rebasing: std::iter::once(path.clone()).collect(),
10885 pushing: HashSet::new(),
10886 },
10887 );
10888 assert!(busy.rebasing, "the registry's transient mark rides through");
10889 assert_eq!(
10890 serde_json::to_value(&busy).unwrap()["rebasing"],
10891 serde_json::Value::Bool(true)
10892 );
10893 }
10894
10895 #[test]
10896 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10897 assert_eq!(note_kinds(&[]), "-");
10898 assert_eq!(
10899 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10900 "dirty,untracked"
10901 );
10902 }
10903
10904 #[test]
10905 fn is_self_close_true_only_when_requester_owns_an_open_window() {
10906 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10907 assert!(is_self_close(Some("w1"), &windows));
10908 assert!(
10909 !is_self_close(Some("w3"), &windows),
10910 "requester owns no window"
10911 );
10912 assert!(!is_self_close(None, &windows), "no requester");
10913 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10914 }
10915
10916 #[test]
10917 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10918 let logs = capture_info(|| {
10919 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10920 assert_eq!(reply, json!({ "removed": true }));
10921 });
10922 assert!(
10923 logs.contains("worktrees close: linked worktree pruned"),
10924 "a successful prune must log an INFO audit line, got: {logs}"
10925 );
10926 assert!(
10927 logs.contains("/wt/feature"),
10928 "the target path must ride the line, got: {logs}"
10929 );
10930 }
10931
10932 #[test]
10933 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10934 let logs = capture_info(|| {
10939 let reply =
10940 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10941 assert_eq!(reply, json!({ "removed": true }));
10942 });
10943 assert!(
10944 logs.contains("worktrees close: nothing to prune, worktree already removed"),
10945 "an already-gone close must log its own outcome, got: {logs}"
10946 );
10947 assert!(
10948 !logs.contains("linked worktree pruned"),
10949 "an already-gone close must not claim it pruned, got: {logs}"
10950 );
10951 }
10952
10953 #[test]
10954 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10955 let logs = capture_info(|| {
10957 let err = log_close_error(
10958 Path::new("/wt/feature"),
10959 "safety check",
10960 anyhow!("not a git worktree"),
10961 );
10962 assert_eq!(
10963 err.to_string(),
10964 "not a git worktree",
10965 "err propagates unchanged"
10966 );
10967 });
10968 assert!(
10969 logs.contains("worktrees close: safety check failed"),
10970 "a failed phase must log an ERROR audit line, got: {logs}"
10971 );
10972 assert!(
10973 logs.contains("not a git worktree"),
10974 "the cause must ride the line, got: {logs}"
10975 );
10976 assert!(
10977 logs.contains("/wt/feature"),
10978 "the target path must ride the line, got: {logs}"
10979 );
10980 }
10981
10982 #[test]
10983 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10984 let logs = capture_info(|| {
10985 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10986 assert!(err.is_err(), "a prune failure must propagate");
10987 });
10988 assert!(
10989 logs.contains("worktrees close: worktree prune failed"),
10990 "a prune failure must log a WARN audit line, got: {logs}"
10991 );
10992 assert!(
10993 logs.contains("locked"),
10994 "the failure cause must ride the line, got: {logs}"
10995 );
10996 }
10997
10998 #[test]
10999 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
11000 let git = GitSafety {
11001 is_main: false,
11002 removable: true,
11003 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
11004 info: vec![],
11005 };
11006 let logs = capture_info(|| {
11007 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
11008 });
11009 assert!(
11010 logs.contains("worktrees close: safety check"),
11011 "phase-1 must log a safety-check line, got: {logs}"
11012 );
11013 assert!(
11014 logs.contains("/wt/feature"),
11015 "the path must ride the line, got: {logs}"
11016 );
11017 assert!(
11018 logs.contains("window_key=\"win-42\""),
11019 "the owning window key must ride the line, got: {logs}"
11020 );
11021 assert!(logs.contains("removable=true"), "got: {logs}");
11022 assert!(logs.contains("is_main=false"), "got: {logs}");
11023 assert!(logs.contains("open=true"), "got: {logs}");
11024 assert!(
11025 logs.contains("risks=dirty,untracked"),
11026 "the blocking risk kinds must ride the line, got: {logs}"
11027 );
11028 }
11029
11030 #[test]
11031 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
11032 let git = GitSafety {
11033 is_main: false,
11034 removable: true,
11035 risks: vec![],
11036 info: vec![],
11037 };
11038 let logs = capture_info(|| {
11039 log_safety_check(Path::new("/wt/feature"), None, &git, false);
11040 });
11041 assert!(
11042 logs.contains("window_key=\"-\""),
11043 "no owning window → dash, got: {logs}"
11044 );
11045 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11046 }
11047
11048 #[test]
11049 fn log_executing_logs_the_routing_decision() {
11050 let logs = capture_info(|| {
11051 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11052 });
11053 assert!(
11054 logs.contains("worktrees close: executing"),
11055 "phase-2 must log the execute routing, got: {logs}"
11056 );
11057 assert!(
11058 logs.contains("requester=\"win-7\""),
11059 "the requester key must ride the line, got: {logs}"
11060 );
11061 assert!(logs.contains("remove=true"), "got: {logs}");
11062 assert!(logs.contains("self_close=false"), "got: {logs}");
11063 assert!(logs.contains("cross_window=3"), "got: {logs}");
11064 }
11065
11066 #[test]
11067 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11068 let logs = capture_info(|| {
11069 log_close_abort(
11070 Path::new("/wt/feature"),
11071 &anyhow!("window(s) did not close in time: win-9"),
11072 );
11073 });
11074 assert!(
11075 logs.contains("worktrees close: aborted"),
11076 "an abort must log a WARN audit line, got: {logs}"
11077 );
11078 assert!(
11079 logs.contains("/wt/feature"),
11080 "the path must ride the line, got: {logs}"
11081 );
11082 assert!(
11083 logs.contains("win-9"),
11084 "the still-open window must ride the line, got: {logs}"
11085 );
11086 }
11087
11088 #[test]
11089 fn log_window_closed_logs_the_no_removal_outcome() {
11090 let logs = capture_info(|| {
11091 log_window_closed(Path::new("/wt/feature"));
11092 });
11093 assert!(
11094 logs.contains("worktrees close: window closed, no removal"),
11095 "a remove:false close must log the no-removal outcome, got: {logs}"
11096 );
11097 assert!(
11098 logs.contains("/wt/feature"),
11099 "the path must ride the line, got: {logs}"
11100 );
11101 }
11102
11103 #[test]
11104 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11105 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11109 let admin = main.path().join(".git").join("worktrees").join("feature");
11110 assert!(admin.exists(), "admin metadata should exist before removal");
11111
11112 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11113
11114 assert!(!wt_path.exists(), "the working directory should be gone");
11115 assert!(!admin.exists(), "the admin metadata should be pruned");
11116 let main_repo = Repository::open(main.path()).unwrap();
11117 assert_eq!(
11118 main_repo.worktrees().unwrap().len(),
11119 0,
11120 "git should no longer track the worktree"
11121 );
11122 }
11123
11124 #[test]
11125 fn remove_worktree_recovers_a_half_removed_orphan() {
11126 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11131 let admin = main.path().join(".git").join("worktrees").join("feature");
11132 std::fs::remove_dir_all(&admin).unwrap();
11134 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11135 assert!(
11136 Repository::open(&wt_path).is_err(),
11137 "the orphan should not open as a repo"
11138 );
11139
11140 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11141 assert!(
11142 !wt_path.exists(),
11143 "the leftover directory should be removed"
11144 );
11145 }
11146
11147 fn orphaned_admin_worktree() -> (
11155 tempfile::TempDir,
11156 PathBuf,
11157 tempfile::TempDir,
11158 PathBuf,
11159 PathBuf,
11160 ) {
11161 let main_dir = tempfile::tempdir().unwrap();
11162 let main_root = main_dir.path().canonicalize().unwrap();
11163 let repo = init_repo(&main_root);
11164 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11165 repo.set_head("refs/heads/trunk").unwrap();
11166 let wt_parent = tempfile::tempdir().unwrap();
11167 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11168 add_worktree(&repo, a, &wt_path, "feature");
11169 let admin = main_root.join(".git").join("worktrees").join("feature");
11170 assert!(admin.exists(), "admin metadata exists before the orphaning");
11171 std::fs::remove_dir_all(&wt_path).unwrap();
11173 (main_dir, main_root, wt_parent, wt_path, admin)
11174 }
11175
11176 fn window_on(folder: &Path) -> WindowEntry {
11177 WindowEntry {
11178 key: "w".to_string(),
11179 folders: vec![folder.to_path_buf()],
11180 repo: None,
11181 title: None,
11182 pid: None,
11183 last_seen: Utc::now(),
11184 }
11185 }
11186
11187 #[test]
11188 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11189 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11193
11194 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11195
11196 assert_eq!(
11197 removed,
11198 Removal::Pruned,
11199 "the orphaned admin must be pruned"
11200 );
11201 assert!(!admin.exists(), "the admin metadata should be gone");
11202 let main_repo = Repository::open(&main_root).unwrap();
11203 assert!(
11204 main_repo.worktrees().unwrap().is_empty(),
11205 "git should no longer track the orphaned worktree"
11206 );
11207 }
11208
11209 #[test]
11210 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11211 let main_dir = tempfile::tempdir().unwrap();
11215 let main_root = main_dir.path().canonicalize().unwrap();
11216 let repo = init_repo(&main_root);
11217 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11218 repo.set_head("refs/heads/trunk").unwrap();
11219 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11221 let wt_path = main_root.join(".nested").join("feature-wt");
11222 add_worktree(&repo, a, &wt_path, "feature");
11223 let admin = main_root.join(".git").join("worktrees").join("feature");
11224 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11225
11226 let removed = remove_worktree(&wt_path, &[]).unwrap();
11227
11228 assert_eq!(removed, Removal::Pruned);
11229 assert!(!admin.exists(), "the admin metadata should be gone");
11230 }
11231
11232 #[test]
11233 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11234 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11238 std::fs::remove_dir_all(&admin).unwrap();
11240
11241 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11242
11243 assert_eq!(removed, Removal::AlreadyGone);
11244 }
11245
11246 #[test]
11247 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11248 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11249 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11252 assert!(
11253 roots.contains(&main_root),
11254 "the owning main repo must be a candidate, got: {roots:?}"
11255 );
11256 }
11257
11258 #[test]
11259 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11260 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11264 let junk = tempfile::tempdir().unwrap();
11265
11266 let removed =
11267 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11268
11269 assert_eq!(removed, Removal::Pruned);
11270 assert!(
11271 !admin.exists(),
11272 "the real owner must still prune the orphan"
11273 );
11274 }
11275
11276 #[test]
11277 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11278 let main_dir = tempfile::tempdir().unwrap();
11282 let main_root = main_dir.path().canonicalize().unwrap();
11283 let repo = init_repo(&main_root);
11284 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11285 repo.set_head("refs/heads/trunk").unwrap();
11286 let wt_parent = tempfile::tempdir().unwrap();
11287 let wt_root = wt_parent.path().canonicalize().unwrap();
11288 let orphan = wt_root.join("orphan-wt");
11289 let sibling = wt_root.join("sibling-wt");
11290 add_worktree(&repo, a, &orphan, "orphan");
11291 add_worktree(&repo, a, &sibling, "sibling");
11292 let admin = main_root.join(".git").join("worktrees").join("orphan");
11293 std::fs::remove_dir_all(&orphan).unwrap();
11294
11295 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11298
11299 assert_eq!(removed, Removal::Pruned);
11300 assert!(
11301 !admin.exists(),
11302 "the orphan's admin metadata must be pruned"
11303 );
11304 }
11305
11306 #[test]
11307 fn prune_orphaned_admin_refuses_a_locked_orphan() {
11308 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11312 let main_repo = Repository::open(&main_root).unwrap();
11313 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11314 main_repo
11315 .find_worktree(&name)
11316 .unwrap()
11317 .lock(Some("in use"))
11318 .unwrap();
11319
11320 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11321
11322 assert!(
11323 err.to_string().contains("locked"),
11324 "a locked orphan must be refused, got: {err:#}"
11325 );
11326 assert!(admin.exists(), "a refused prune must leave the admin entry");
11327 }
11328
11329 #[test]
11330 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11331 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11332 assert!(!is_orphaned_worktree(&wt_path));
11334 assert!(!is_orphaned_worktree(main.path()));
11336 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11338 .unwrap();
11339 assert!(is_orphaned_worktree(&wt_path));
11340 }
11341
11342 #[test]
11343 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11344 let tmp = tempfile::tempdir().unwrap();
11345 let missing = tmp.path().join("gone");
11346 assert!(remove_dir_all_retrying(&missing).is_ok());
11347 }
11348
11349 #[test]
11350 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11351 use std::io::Error;
11352 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11353 assert!(
11354 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11355 "errno {errno} is the concurrent-writer race and must be retried"
11356 );
11357 }
11358 for errno in [
11361 nix::libc::EACCES,
11362 nix::libc::EPERM,
11363 nix::libc::EROFS,
11364 nix::libc::ENOTDIR,
11365 ] {
11366 assert!(
11367 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11368 "errno {errno} is permanent and must not be retried"
11369 );
11370 }
11371 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11373 }
11374
11375 #[test]
11376 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11377 let tmp = tempfile::tempdir().unwrap();
11381 let file = tmp.path().join("not-a-directory");
11382 std::fs::write(&file, b"x").unwrap();
11383
11384 let mut attempts = 0;
11385 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11386 attempts += 1;
11387 std::fs::remove_dir_all(&file)
11388 })
11389 .unwrap_err();
11390
11391 assert_eq!(attempts, 1, "a permanent error must not be retried");
11392 assert!(
11393 err.to_string()
11394 .contains("failed to remove worktree directory"),
11395 "unexpected error: {err:#}"
11396 );
11397 assert!(err.source().is_some(), "the io::Error cause is preserved");
11398 assert!(file.exists());
11399 }
11400
11401 #[test]
11402 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11403 let tmp = tempfile::tempdir().unwrap();
11407 let mut attempts = 0;
11408 let backoff = [Duration::ZERO, Duration::ZERO];
11409 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11410 attempts += 1;
11411 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11412 })
11413 .unwrap_err();
11414
11415 assert_eq!(attempts, backoff.len() + 1);
11417 assert!(
11418 err.to_string()
11419 .contains("failed to remove worktree directory"),
11420 "unexpected error: {err:#}"
11421 );
11422 }
11423
11424 #[test]
11425 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11426 let tmp = tempfile::tempdir().unwrap();
11429 let mut attempts = 0;
11430 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11431 attempts += 1;
11432 if attempts < 3 {
11433 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11434 } else {
11435 Ok(())
11436 }
11437 });
11438 assert!(result.is_ok(), "{result:?}");
11439 assert_eq!(attempts, 3);
11440 }
11441
11442 #[test]
11443 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11444 let tmp = tempfile::tempdir().unwrap();
11447 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11448 assert!(!is_orphaned_worktree(tmp.path()));
11449 }
11450
11451 #[test]
11452 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11453 let tmp = tempfile::tempdir().unwrap();
11456 let plain = tmp.path().join("plain");
11457 std::fs::create_dir(&plain).unwrap();
11458
11459 let err = remove_worktree(&plain, &[]).unwrap_err();
11460
11461 assert!(
11462 err.to_string().contains("not a git worktree"),
11463 "unexpected error: {err:#}"
11464 );
11465 assert!(plain.exists(), "a non-worktree path must be left alone");
11466 }
11467
11468 #[test]
11469 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11470 use std::sync::atomic::{AtomicBool, Ordering};
11476 use std::sync::Arc;
11477
11478 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11479 let nested = wt_path.join("target").join("nested");
11485 std::fs::create_dir_all(&nested).unwrap();
11486
11487 let stop = Arc::new(AtomicBool::new(false));
11488 let writer_stop = Arc::clone(&stop);
11489 let writer_dir = nested;
11490 let writer = std::thread::spawn(move || {
11491 let mut n = 0u64;
11492 let deadline = std::time::Instant::now() + Duration::from_millis(400);
11495 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11496 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11501 n += 1;
11502 }
11503 });
11504
11505 let result = remove_worktree(&wt_path, &[]);
11506 stop.store(true, Ordering::Relaxed);
11507 writer.join().unwrap();
11508
11509 assert!(
11510 result.is_ok(),
11511 "removal should retry past the writer: {result:?}"
11512 );
11513 assert!(!wt_path.exists(), "the worktree directory should be gone");
11514 }
11515
11516 #[tokio::test]
11517 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11518 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11519 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11521
11522 let svc = WorktreesService::new();
11523 let report = svc
11524 .handle("close", json!({ "path": wt_path, "remove": true }))
11525 .await
11526 .unwrap();
11527 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11528 assert!(
11529 risks
11530 .iter()
11531 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11532 "expected an untracked risk: {report}"
11533 );
11534 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11536 assert!(wt_path.exists());
11538 }
11539
11540 #[tokio::test]
11541 async fn close_confirmed_removes_a_dirty_worktree() {
11542 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11543 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11544 let svc = WorktreesService::new();
11545 let reply = svc
11547 .handle(
11548 "close",
11549 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11550 )
11551 .await
11552 .unwrap();
11553 assert_eq!(reply, json!({ "removed": true }));
11554 assert!(!wt_path.exists());
11555 }
11556
11557 #[tokio::test]
11558 async fn close_refuses_to_remove_the_main_working_tree() {
11559 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11560 let svc = WorktreesService::new();
11561 let report = svc
11563 .handle("close", json!({ "path": main.path(), "remove": true }))
11564 .await
11565 .unwrap();
11566 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11567 assert_eq!(
11568 report.get("removable").and_then(Value::as_bool),
11569 Some(false)
11570 );
11571 assert!(svc
11574 .handle(
11575 "close",
11576 json!({ "path": main.path(), "remove": true, "confirmed": true }),
11577 )
11578 .await
11579 .is_err());
11580 assert!(main.path().exists());
11581 }
11582
11583 #[tokio::test]
11584 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11585 let main_dir = tempfile::tempdir().unwrap();
11589 let repo = init_repo(main_dir.path());
11590 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11591 repo.set_head("refs/heads/trunk").unwrap();
11592 let wt_parent = tempfile::tempdir().unwrap();
11593 let wt_path = wt_parent.path().join("main-wt");
11594 add_worktree(&repo, a, &wt_path, "main");
11595
11596 let svc = WorktreesService::new();
11597 let reply = svc
11598 .handle(
11599 "close",
11600 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11601 )
11602 .await
11603 .unwrap();
11604 assert_eq!(reply, json!({ "removed": true }));
11605 assert!(!wt_path.exists());
11606 assert!(
11608 repo.find_branch("main", git2::BranchType::Local).is_ok(),
11609 "the default branch must survive worktree removal"
11610 );
11611 }
11612
11613 #[tokio::test]
11614 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11615 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11616 let svc = WorktreesService::new();
11617 svc.handle(
11619 "close",
11620 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11621 )
11622 .await
11623 .unwrap();
11624 let reply = svc
11627 .handle(
11628 "close",
11629 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11630 )
11631 .await
11632 .unwrap();
11633 assert_eq!(reply, json!({ "removed": true }));
11634 }
11635
11636 #[tokio::test]
11637 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11638 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11643 let svc = WorktreesService::new();
11644 svc.handle(
11647 "register",
11648 register_payload("main-w", None, &main_root.display().to_string()),
11649 )
11650 .await
11651 .unwrap();
11652
11653 let before = svc.handle("tree", Value::Null).await.unwrap();
11655 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11656 assert_eq!(
11657 worktrees_before, 2,
11658 "the orphaned row is present before close"
11659 );
11660
11661 let reply = svc
11662 .handle(
11663 "close",
11664 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11665 )
11666 .await
11667 .unwrap();
11668 assert_eq!(reply, json!({ "removed": true }));
11669
11670 assert!(!admin.exists(), "the admin metadata must be pruned");
11672 let after = svc.handle("tree", Value::Null).await.unwrap();
11673 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11674 assert_eq!(worktrees_after, 1, "only the main working tree remains");
11675 }
11676
11677 #[tokio::test]
11678 async fn close_safety_check_detects_detached_head_unreachable_commits() {
11679 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11680 let wt_repo = Repository::open(&wt_path).unwrap();
11683 let parent_oid = wt_repo.head().unwrap().target().unwrap();
11684 let parent = wt_repo.find_commit(parent_oid).unwrap();
11685 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11686 wt_repo.set_head_detached(orphan).unwrap();
11687
11688 let svc = WorktreesService::new();
11689 let report = svc
11690 .handle("close", json!({ "path": wt_path, "remove": true }))
11691 .await
11692 .unwrap();
11693 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11694 assert!(
11695 risks
11696 .iter()
11697 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11698 "expected an unreachable-commits risk: {report}"
11699 );
11700 }
11701
11702 #[tokio::test]
11703 async fn close_self_close_removes_when_the_requester_owns_the_target() {
11704 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11705 let svc = WorktreesService::new();
11706 svc.handle(
11710 "register",
11711 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11712 )
11713 .await
11714 .unwrap();
11715 let reply = svc
11716 .handle(
11717 "close",
11718 json!({
11719 "path": wt_path,
11720 "remove": true,
11721 "confirmed": true,
11722 "requester_key": "w1",
11723 }),
11724 )
11725 .await
11726 .unwrap();
11727 assert_eq!(reply, json!({ "removed": true }));
11728 assert!(!wt_path.exists());
11729 }
11730
11731 #[tokio::test]
11732 async fn close_safety_check_surfaces_the_owning_window() {
11733 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11734 let svc = WorktreesService::new();
11735 svc.handle(
11738 "register",
11739 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11740 )
11741 .await
11742 .unwrap();
11743 let report = svc
11744 .handle("close", json!({ "path": wt_path, "remove": true }))
11745 .await
11746 .unwrap();
11747 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11748 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11749 assert_eq!(
11750 report.get("window_folder_count").and_then(Value::as_u64),
11751 Some(2)
11752 );
11753 }
11754
11755 #[tokio::test]
11756 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11757 let svc = WorktreesService::new();
11758 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11759 .await
11760 .unwrap();
11761 assert_eq!(
11763 svc.handle("heartbeat", json!({ "key": "w1" }))
11764 .await
11765 .unwrap(),
11766 json!({ "known": true })
11767 );
11768 svc.registry.mark_close_pending("w1");
11770 assert_eq!(
11771 svc.handle("heartbeat", json!({ "key": "w1" }))
11772 .await
11773 .unwrap(),
11774 json!({ "known": true, "close": true })
11775 );
11776 assert_eq!(
11777 svc.handle("heartbeat", json!({ "key": "w1" }))
11778 .await
11779 .unwrap(),
11780 json!({ "known": true })
11781 );
11782 }
11783
11784 #[tokio::test]
11787 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11788 let svc = WorktreesService::new();
11789 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11790 .await
11791 .unwrap();
11792 assert_eq!(
11795 svc.handle("heartbeat", json!({ "key": "w1" }))
11796 .await
11797 .unwrap(),
11798 json!({ "known": true })
11799 );
11800 svc.registry.mark_reload_pending("w1");
11802 assert_eq!(
11803 svc.handle("heartbeat", json!({ "key": "w1" }))
11804 .await
11805 .unwrap(),
11806 json!({ "known": true, "reload": true })
11807 );
11808 assert_eq!(
11809 svc.handle("heartbeat", json!({ "key": "w1" }))
11810 .await
11811 .unwrap(),
11812 json!({ "known": true })
11813 );
11814 }
11815
11816 #[tokio::test]
11817 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11818 let svc = WorktreesService::new();
11819 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11820 .await
11821 .unwrap();
11822 svc.registry.mark_close_pending("w1");
11826 svc.registry.mark_reload_pending("w1");
11827 assert_eq!(
11828 svc.handle("heartbeat", json!({ "key": "w1" }))
11829 .await
11830 .unwrap(),
11831 json!({ "known": true, "close": true, "reload": true })
11832 );
11833 assert_eq!(
11834 svc.handle("heartbeat", json!({ "key": "w1" }))
11835 .await
11836 .unwrap(),
11837 json!({ "known": true })
11838 );
11839 }
11840
11841 #[tokio::test]
11842 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11843 let svc = WorktreesService::new();
11844 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11845 .await
11846 .unwrap();
11847 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11848 .await
11849 .unwrap();
11850
11851 let reply = svc
11854 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11855 .await
11856 .unwrap();
11857 assert_eq!(
11858 reply,
11859 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11860 );
11861
11862 assert!(svc.registry.take_reload_pending("w1"));
11865 assert!(svc.registry.take_reload_pending("w2"));
11866 assert!(!svc.registry.take_reload_pending("ghost"));
11867 }
11868
11869 #[tokio::test]
11870 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11871 let svc = WorktreesService::new();
11872 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11873 .await
11874 .unwrap();
11875
11876 assert_eq!(
11879 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11880 .await
11881 .unwrap(),
11882 json!({ "requested": 1, "signalled": 1, "unknown": [] })
11883 );
11884
11885 assert_eq!(
11888 svc.handle("reload", json!({ "target_keys": [] }))
11889 .await
11890 .unwrap(),
11891 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11892 );
11893 assert_eq!(
11894 svc.handle("reload", json!({})).await.unwrap(),
11895 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11896 );
11897 }
11898
11899 #[tokio::test]
11900 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11901 let svc = WorktreesService::new();
11902 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11903 .await
11904 .unwrap();
11905 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11906 .await
11907 .unwrap();
11908
11909 svc.handle("reload", json!({ "target_keys": ["w2"] }))
11912 .await
11913 .unwrap();
11914 assert_eq!(
11915 svc.handle("heartbeat", json!({ "key": "w2" }))
11916 .await
11917 .unwrap(),
11918 json!({ "known": true, "reload": true })
11919 );
11920 assert_eq!(
11922 svc.handle("heartbeat", json!({ "key": "w1" }))
11923 .await
11924 .unwrap(),
11925 json!({ "known": true })
11926 );
11927 }
11928
11929 #[tokio::test]
11930 async fn reload_op_treats_an_unregistered_window_as_unknown() {
11931 let svc = WorktreesService::new();
11932 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11933 .await
11934 .unwrap();
11935 svc.handle("unregister", json!({ "key": "w1" }))
11936 .await
11937 .unwrap();
11938 assert_eq!(
11941 svc.handle("reload", json!({ "target_keys": ["w1"] }))
11942 .await
11943 .unwrap(),
11944 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11945 );
11946 }
11947
11948 #[tokio::test]
11949 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11950 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11951 let svc = Arc::new(WorktreesService::new());
11952 svc.handle(
11954 "register",
11955 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11956 )
11957 .await
11958 .unwrap();
11959
11960 let svc2 = svc.clone();
11963 let path = wt_path.clone();
11964 let close = tokio::spawn(async move {
11965 svc2.handle(
11966 "close",
11967 json!({
11968 "path": path,
11969 "remove": true,
11970 "confirmed": true,
11971 "requester_key": "w1",
11972 }),
11973 )
11974 .await
11975 });
11976
11977 let mut saw_close = false;
11980 for _ in 0..200 {
11981 let hb = svc
11982 .handle("heartbeat", json!({ "key": "w2" }))
11983 .await
11984 .unwrap();
11985 if hb.get("close").and_then(Value::as_bool) == Some(true) {
11986 saw_close = true;
11987 svc.handle("unregister", json!({ "key": "w2" }))
11988 .await
11989 .unwrap();
11990 break;
11991 }
11992 tokio::time::sleep(Duration::from_millis(5)).await;
11993 }
11994 assert!(saw_close, "w2 should have been told to close");
11995
11996 let reply = close.await.unwrap().unwrap();
11998 assert_eq!(reply, json!({ "removed": true }));
11999 assert!(!wt_path.exists());
12000 }
12001
12002 #[tokio::test]
12003 async fn await_windows_closed_times_out_when_a_window_never_closes() {
12004 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12005 let svc = WorktreesService::new();
12006 svc.handle(
12007 "register",
12008 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12009 )
12010 .await
12011 .unwrap();
12012 let err = await_windows_closed(
12015 &svc.registry,
12016 &wt_path,
12017 Some("w1"),
12018 Duration::from_millis(150),
12019 Duration::from_millis(25),
12020 )
12021 .await
12022 .unwrap_err();
12023 assert!(
12024 err.to_string().contains("w2"),
12025 "error names the window: {err}"
12026 );
12027 await_windows_closed(
12029 &svc.registry,
12030 &wt_path,
12031 Some("w2"),
12032 Duration::from_millis(150),
12033 Duration::from_millis(25),
12034 )
12035 .await
12036 .unwrap();
12037 }
12038
12039 #[tokio::test]
12040 async fn close_window_without_remove_replies_closed_and_never_deletes() {
12041 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12042 let svc = WorktreesService::new();
12043 let reply = svc
12045 .handle("close", json!({ "path": main.path(), "remove": false }))
12046 .await
12047 .unwrap();
12048 assert_eq!(reply, json!({ "closed": true }));
12049 assert!(main.path().exists());
12050 }
12051
12052 #[tokio::test]
12053 async fn close_safety_check_flags_modified_tracked_files() {
12054 let main_dir = tempfile::tempdir().unwrap();
12058 let repo = init_repo(main_dir.path());
12059 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12060 repo.set_head("refs/heads/trunk").unwrap();
12061 let wt_parent = tempfile::tempdir().unwrap();
12062 let wt_path = wt_parent.path().join("feature-wt");
12063 add_worktree(&repo, a, &wt_path, "feature");
12064 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12065
12066 let svc = WorktreesService::new();
12067 let report = svc
12068 .handle("close", json!({ "path": wt_path, "remove": true }))
12069 .await
12070 .unwrap();
12071 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12072 assert!(
12073 risks
12074 .iter()
12075 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12076 "expected a dirty risk: {report}"
12077 );
12078 }
12079
12080 #[tokio::test]
12081 async fn close_safety_check_flags_an_in_progress_operation() {
12082 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12085 let wt_repo = Repository::open(&wt_path).unwrap();
12086 let head = wt_repo.head().unwrap().target().unwrap();
12087 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12088 assert_ne!(wt_repo.state(), RepositoryState::Clean);
12089
12090 let svc = WorktreesService::new();
12091 let report = svc
12092 .handle("close", json!({ "path": wt_path, "remove": true }))
12093 .await
12094 .unwrap();
12095 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12096 assert!(
12097 risks
12098 .iter()
12099 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12100 "expected an in-progress risk: {report}"
12101 );
12102 }
12103
12104 #[tokio::test]
12105 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12106 let main_dir = tempfile::tempdir().unwrap();
12110 let repo = init_repo(main_dir.path());
12111 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12112 repo.set_head("refs/heads/trunk").unwrap();
12113 let a_commit = repo.find_commit(a).unwrap();
12114 repo.branch("feature", &a_commit, false).unwrap();
12115 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12116 .unwrap();
12117 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12119 drop(a_commit);
12120 let mut cfg = repo.config().unwrap();
12121 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12122 .unwrap();
12123 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12124 .unwrap();
12125 cfg.set_str("branch.feature.remote", "origin").unwrap();
12126 cfg.set_str("branch.feature.merge", "refs/heads/feature")
12127 .unwrap();
12128 let wt_parent = tempfile::tempdir().unwrap();
12131 let wt_path = wt_parent.path().join("feature-wt");
12132 let reference = repo.find_reference("refs/heads/feature").unwrap();
12133 let mut opts = git2::WorktreeAddOptions::new();
12134 opts.reference(Some(&reference));
12135 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12136
12137 let svc = WorktreesService::new();
12138 let report = svc
12139 .handle("close", json!({ "path": wt_path, "remove": true }))
12140 .await
12141 .unwrap();
12142 let info = report.get("info").and_then(Value::as_array).unwrap();
12145 assert!(
12146 info.iter()
12147 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12148 "expected an unpushed info note: {report}"
12149 );
12150 assert!(
12151 report
12152 .get("risks")
12153 .and_then(Value::as_array)
12154 .unwrap()
12155 .is_empty(),
12156 "unpushed commits alone must not block: {report}"
12157 );
12158 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12159 }
12160
12161 #[tokio::test]
12162 async fn close_safety_check_ignores_gitignored_files() {
12163 let main_dir = tempfile::tempdir().unwrap();
12167 let repo = init_repo(main_dir.path());
12168 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12169 repo.set_head("refs/heads/trunk").unwrap();
12170 let wt_parent = tempfile::tempdir().unwrap();
12171 let wt_path = wt_parent.path().join("feature-wt");
12172 add_worktree(&repo, a, &wt_path, "feature");
12173 std::fs::create_dir(wt_path.join("build")).unwrap();
12174 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12175
12176 let svc = WorktreesService::new();
12177 let report = svc
12178 .handle("close", json!({ "path": wt_path, "remove": true }))
12179 .await
12180 .unwrap();
12181 assert!(
12182 report
12183 .get("risks")
12184 .and_then(Value::as_array)
12185 .unwrap()
12186 .is_empty(),
12187 "a gitignored file must not create a risk: {report}"
12188 );
12189 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12190 }
12191
12192 #[tokio::test]
12193 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12194 let svc = WorktreesService::new();
12197 let report = svc
12198 .handle(
12199 "close",
12200 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12201 )
12202 .await
12203 .unwrap();
12204 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12205 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12206 assert!(report
12207 .get("risks")
12208 .and_then(Value::as_array)
12209 .unwrap()
12210 .is_empty());
12211 let info = report.get("info").and_then(Value::as_array).unwrap();
12212 assert!(info
12213 .iter()
12214 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12215 }
12216
12217 #[tokio::test]
12218 async fn close_phase1_errors_on_a_non_git_worktree_path() {
12219 let dir = tempfile::tempdir().unwrap();
12224 let svc = WorktreesService::new();
12225 let result = svc
12226 .handle("close", json!({ "path": dir.path(), "remove": true }))
12227 .await;
12228 assert!(
12229 result.is_err(),
12230 "a non-git-worktree target must error the safety check, got: {result:?}"
12231 );
12232 }
12233
12234 #[tokio::test]
12235 async fn close_refuses_a_locked_worktree() {
12236 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12239 let main_repo = Repository::open(main.path()).unwrap();
12240 main_repo
12241 .find_worktree("feature")
12242 .unwrap()
12243 .lock(Some("under test"))
12244 .unwrap();
12245
12246 let svc = WorktreesService::new();
12247 let err = svc
12248 .handle(
12249 "close",
12250 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12251 )
12252 .await
12253 .unwrap_err();
12254 assert!(
12255 err.to_string().contains("locked"),
12256 "expected a locked error: {err}"
12257 );
12258 assert!(wt_path.exists(), "a locked worktree must not be removed");
12259 }
12260
12261 #[tokio::test]
12262 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12263 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12267 let wt_repo = Repository::open(&wt_path).unwrap();
12268 let tip = wt_repo.head().unwrap().target().unwrap();
12271 wt_repo.set_head_detached(tip).unwrap();
12272 assert!(wt_repo.head_detached().unwrap());
12273
12274 let svc = WorktreesService::new();
12275 let report = svc
12276 .handle("close", json!({ "path": wt_path, "remove": true }))
12277 .await
12278 .unwrap();
12279 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12280 assert!(
12281 !risks
12282 .iter()
12283 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12284 "a detached HEAD reachable from a branch must not be flagged: {report}"
12285 );
12286 }
12287
12288 #[test]
12289 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12290 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12291 let main_repo = Repository::open(main.path()).unwrap();
12292 assert_eq!(
12294 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12295 "feature"
12296 );
12297 let err =
12300 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12301 assert!(
12302 err.to_string().contains("not registered"),
12303 "expected a not-registered error: {err}"
12304 );
12305 }
12306
12307 #[test]
12308 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12309 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12312 let repo = Repository::open(&wt_path).unwrap();
12313 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12314 assert!(
12317 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12318 "a corrupt index should make statuses() fail"
12319 );
12320 assert_eq!(count_dirty_untracked(&repo), (0, 0));
12321 }
12322}