1mod divergence;
34mod geometry;
35mod shared_repo;
36
37use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
38use std::path::{Path, PathBuf};
39use std::process::{Command, Stdio};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, Mutex, PoisonError};
42use std::time::{Duration, Instant};
43
44use anyhow::{anyhow, bail, Context, Result};
45use chrono::{DateTime, Utc};
46
47use crate::git::remote::RemoteInfo;
48use crate::git::worktree_batch::Selection;
49use crate::git::worktree_push;
50use crate::git::worktree_rebase;
51use crate::github_rate_limit::{
52 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
53};
54use crate::pr_status::{
55 EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
56};
57use async_trait::async_trait;
58use divergence::{AheadBehindCoordinator, Divergence};
59use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
60use serde::{Deserialize, Serialize};
61use serde_json::{json, Value};
62use tokio::sync::watch;
63use tokio::sync::Mutex as AsyncMutex;
64use tokio::task::JoinHandle;
65use tokio_util::sync::CancellationToken;
66
67use crate::daemon::service::{
68 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
69};
70use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
71
72pub const SERVICE_NAME: &str = "worktrees";
74
75const SUBMENU_TITLE: &str = "Worktrees";
77
78const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
81
82const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
85
86const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
98
99fn menu_refresh_interval() -> Duration {
102 crate::daemon::server::duration_secs_from_env(
103 ENV_MENU_REFRESH_INTERVAL,
104 DEFAULT_MENU_REFRESH_INTERVAL,
105 )
106}
107
108const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
112
113const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
121
122const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
130
131const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
137
138const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
144
145const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
151
152const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
156
157const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
164
165fn pr_poll_interval() -> Duration {
168 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
169}
170
171fn pr_debounce_interval() -> Duration {
174 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
175}
176
177const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
181
182const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
187
188const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
192
193const OPEN_PR_LIST_LIMIT: &str = "100";
196
197fn open_pr_ttl() -> Duration {
200 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
201}
202
203const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
207
208const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
215
216fn rate_limit_poll_interval() -> Duration {
219 crate::daemon::server::duration_secs_from_env(
220 ENV_RATE_LIMIT_POLL_INTERVAL,
221 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
222 )
223}
224
225struct RefreshTask {
227 token: CancellationToken,
229 handle: JoinHandle<()>,
231}
232
233fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
250 grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
251}
252
253fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
268 next.iter().any(|w| !prev.contains(w))
269}
270
271fn next_pr_poll_delay(
289 current: Duration,
290 base: Duration,
291 pending: bool,
292 since_moved: Option<Duration>,
293) -> Duration {
294 if !pending {
295 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
296 }
297 match since_moved {
298 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
300 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
303 }
304}
305
306fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
318 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
319 delay.max(BUDGET_THROTTLE_INTERVAL)
320 } else {
321 delay
322 }
323}
324
325fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
333 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
334 [
338 (prev.and_then(|p| p.graphql), next.graphql),
339 (prev.and_then(|p| p.core), next.core),
340 (prev.and_then(|p| p.search), next.search),
341 ]
342 .into_iter()
343 .any(|(before, after)| over(after) && !over(before))
344}
345
346struct PollerTask {
348 token: CancellationToken,
350 handle: JoinHandle<()>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
371struct PrWatch {
372 target: PrTarget,
374 upstream_sha: Option<String>,
376}
377
378fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
387 let mut out = Vec::new();
388 for repo in snapshot
389 .get("repos")
390 .and_then(Value::as_array)
391 .into_iter()
392 .flatten()
393 {
394 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
399 continue;
400 }
401 let Some(github) = repo.get("github") else {
402 continue;
403 };
404 let (Some(owner), Some(name)) = (
405 github.get("owner").and_then(Value::as_str),
406 github.get("name").and_then(Value::as_str),
407 ) else {
408 continue;
409 };
410 for wt in repo
411 .get("worktrees")
412 .and_then(Value::as_array)
413 .into_iter()
414 .flatten()
415 {
416 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
417 out.push(PrWatch {
418 upstream_sha: wt
419 .get("upstream_sha")
420 .and_then(Value::as_str)
421 .map(str::to_string),
422 target: PrTarget {
423 owner: owner.to_string(),
424 name: name.to_string(),
425 branch: branch.to_string(),
426 },
427 });
428 }
429 }
430 }
431 out.sort();
432 out.dedup();
433 out
434}
435
436#[cfg(test)]
439fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
440 pr_watch_from_snapshot(snapshot)
441 .into_iter()
442 .map(|w| w.target)
443 .collect()
444}
445
446pub struct WorktreesService {
448 registry: Arc<WorktreesRegistry>,
451 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
457 refresh: Mutex<Option<RefreshTask>>,
459 pr_cache: Arc<PrStatusCache>,
465 poller: Mutex<Option<PollerTask>>,
468 rate_limit_cache: Arc<RateLimitCache>,
476 rate_limit_poller: Mutex<Option<PollerTask>>,
479 tree_cache: Arc<TreeSnapshotCache>,
485 prune_lock: tokio::sync::Mutex<()>,
502 ahead_behind: AheadBehindCoordinator,
506 polling_prefs_path: Mutex<Option<PathBuf>>,
514 pr_cache_path: Mutex<Option<PathBuf>>,
521 pr_warm_start: Mutex<Option<PrWarmStart>>,
526 open_pr_cache: Arc<OpenPrCache>,
531 reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
547 rebase_lock: tokio::sync::Mutex<()>,
559 push_lock: tokio::sync::Mutex<()>,
576}
577
578impl WorktreesService {
579 #[must_use]
584 pub fn new() -> Self {
585 let registry = Arc::new(WorktreesRegistry::new());
586 let pr_cache = Arc::new(PrStatusCache::new());
587 Self {
588 registry: registry.clone(),
589 menu_cache: Arc::new(Mutex::new(None)),
590 refresh: Mutex::new(None),
591 pr_cache: pr_cache.clone(),
592 poller: Mutex::new(None),
593 rate_limit_cache: Arc::new(RateLimitCache::new()),
594 rate_limit_poller: Mutex::new(None),
595 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
596 prune_lock: tokio::sync::Mutex::new(()),
597 ahead_behind: AheadBehindCoordinator::new(),
598 polling_prefs_path: Mutex::new(None),
599 pr_cache_path: Mutex::new(None),
600 pr_warm_start: Mutex::new(None),
601 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
602 reposition_undo: Mutex::new(Vec::new()),
603 rebase_lock: tokio::sync::Mutex::new(()),
604 push_lock: tokio::sync::Mutex::new(()),
605 }
606 }
607
608 pub fn load_polling_prefs(&self, path: PathBuf) {
617 match std::fs::read(&path) {
618 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
619 Ok(prefs) => self
620 .registry
621 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
622 Err(err) => tracing::warn!(
623 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
624 path.display()
625 ),
626 },
627 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
628 Err(err) => tracing::warn!(
629 "could not read worktrees polling prefs at {}: {err:#}",
630 path.display()
631 ),
632 }
633 *self
634 .polling_prefs_path
635 .lock()
636 .unwrap_or_else(PoisonError::into_inner) = Some(path);
637 }
638
639 fn persist_polling_prefs(&self) {
646 let Some(path) = self
647 .polling_prefs_path
648 .lock()
649 .unwrap_or_else(PoisonError::into_inner)
650 .clone()
651 else {
652 return;
653 };
654 let prefs = PollingPrefs {
655 enabled: self
656 .registry
657 .polling_snapshot()
658 .into_iter()
659 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
660 .collect(),
661 };
662 if let Err(err) = write_polling_prefs(&path, &prefs) {
663 tracing::warn!(
664 "could not persist worktrees polling prefs to {}: {err:#}",
665 path.display()
666 );
667 }
668 }
669
670 pub fn load_pr_cache(&self, path: PathBuf) {
683 match std::fs::read(&path) {
684 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
685 Ok(prefs) => {
686 self.pr_cache.seed(
687 prefs
688 .entries
689 .into_iter()
690 .map(|e| (e.target, e.resolution.into_resolution())),
691 );
692 if let Some(polled_at) = prefs.polled_at {
697 let watched = prefs
698 .watched
699 .into_iter()
700 .map(|w| PrWatch {
701 target: w.target,
702 upstream_sha: w.upstream_sha,
703 })
704 .collect();
705 *self
706 .pr_warm_start
707 .lock()
708 .unwrap_or_else(PoisonError::into_inner) =
709 Some(PrWarmStart { watched, polled_at });
710 }
711 }
712 Err(err) => {
716 let at = path.display();
717 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
718 }
719 },
720 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
721 Err(err) => {
722 let at = path.display();
723 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
724 }
725 }
726 *self
727 .pr_cache_path
728 .lock()
729 .unwrap_or_else(PoisonError::into_inner) = Some(path);
730 }
731
732 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
739 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
743 .await
744 }
745
746 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
749 let key = format!("{owner}/{name}");
750 if let Some(prs) = self.open_pr_cache.fresh(&key) {
751 return Ok(prs);
752 }
753 let slug = key.clone();
754 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
755 .await
756 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
757 self.open_pr_cache.store(key, prs.clone());
758 Ok(prs)
759 }
760
761 #[must_use]
765 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
766 self.rate_limit_cache.clone()
767 }
768
769 pub fn start_menu_refresh(&self) {
777 if tokio::runtime::Handle::try_current().is_err() {
778 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
779 return;
780 }
781 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
782 if guard.is_some() {
783 return;
784 }
785 let token = CancellationToken::new();
786 let loop_token = token.clone();
787 let registry = self.registry.clone();
788 let cache = self.menu_cache.clone();
789 let rate_limit_cache = self.rate_limit_cache.clone();
790 let interval = menu_refresh_interval();
793 let handle = tokio::spawn(async move {
794 loop {
795 let entries = registry.list();
799 let rate_limit = rate_limit_cache.get();
802 if let Ok(items) = tokio::task::spawn_blocking(move || {
803 menu_items_for(&entries, rate_limit.as_ref())
804 })
805 .await
806 {
807 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
808 }
809 tokio::select! {
810 () = loop_token.cancelled() => break,
811 () = tokio::time::sleep(interval) => {}
812 }
813 }
814 });
815 *guard = Some(RefreshTask { token, handle });
816 }
817
818 pub fn start_pr_poller(&self) {
849 self.start_pr_poller_with(
852 pr_poll_interval(),
853 pr_debounce_interval(),
854 crate::pr_status::resolve_gh_binary(),
855 );
856 }
857
858 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
869 if tokio::runtime::Handle::try_current().is_err() {
870 tracing::debug!("no tokio runtime; worktrees PR poller not started");
871 return;
872 }
873 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
874 if guard.is_some() {
875 return;
876 }
877 let token = CancellationToken::new();
878 let loop_token = token.clone();
879 let registry = self.registry.clone();
880 let tree_cache = self.tree_cache.clone();
881 let pr_cache = self.pr_cache.clone();
882 let rate_limit_cache = self.rate_limit_cache.clone();
886 let pr_cache_path = self
887 .pr_cache_path
888 .lock()
889 .unwrap_or_else(PoisonError::into_inner)
890 .clone();
891 let warm_start = self
892 .pr_warm_start
893 .lock()
894 .unwrap_or_else(PoisonError::into_inner)
895 .take();
896 let mut changes = self.registry.subscribe_changes();
899 let handle = tokio::spawn(async move {
900 let mut backoff = base;
911 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
918 match warm_start {
919 Some(ws) => {
920 let elapsed = (Utc::now() - ws.polled_at)
921 .to_std()
922 .unwrap_or(Duration::ZERO);
923 (Some(ws.watched), Instant::now().checked_sub(elapsed))
924 }
925 None => (None, None),
926 };
927 let mut moved_at: Option<Instant> = None;
930 'poll: loop {
931 tokio::select! {
934 () = loop_token.cancelled() => break,
935 () = tokio::time::sleep(base) => {}
936 result = changes.changed() => {
939 if result.is_err() {
946 break;
947 }
948 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
956 loop {
957 tokio::select! {
958 () = loop_token.cancelled() => break 'poll,
959 () = tokio::time::sleep(debounce) => break,
960 r = changes.changed() => {
961 if r.is_err() {
962 break 'poll;
963 }
964 if Instant::now() >= overall_deadline {
965 break;
966 }
967 }
968 }
969 }
970 }
971 }
972 let snapshot = tree_cache.snapshot().await;
975 let watch = pr_watch_from_snapshot(&snapshot);
976 if watch.is_empty() {
977 backoff = base;
984 last_poll = None;
985 moved_at = None;
986 continue;
987 }
988 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
992 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
996 pr_cache.retain_targets(&keep);
997 let rate_limit = rate_limit_cache.get();
1003 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
1004 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
1005 let trigger = grew && !over_budget;
1006 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1007 if !grew {
1012 watched = Some(watch);
1013 }
1014 continue;
1015 }
1016 if grew {
1017 backoff = base;
1020 moved_at = Some(Instant::now());
1021 }
1022 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1023 let bin = gh_bin.clone();
1028 let resolved = tokio::task::spawn_blocking(move || {
1029 crate::pr_status::resolve_with_budget(&bin, &targets)
1030 })
1031 .await
1032 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1033 let (pending, resolved_ok) = match resolved {
1040 Ok((resolutions, budget)) => {
1041 if let Some(b) = budget {
1047 tracing::debug!(
1048 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1049 b.cost,
1050 b.used,
1051 b.limit,
1052 b.remaining
1053 );
1054 rate_limit_cache.observe_graphql(RateLimitResource::new(
1055 b.used,
1056 b.limit,
1057 b.remaining,
1058 b.reset,
1059 ));
1060 }
1061 if pr_cache.replace(resolutions) {
1064 registry.bump();
1065 }
1066 (pr_cache.any_pending(), true)
1067 }
1068 Err(err) => {
1069 tracing::debug!("PR badge poll failed: {err:#}");
1070 (false, false)
1071 }
1072 };
1073 last_poll = Some(Instant::now());
1074 watched = Some(watch);
1077 let since_moved = moved_at.map(|at| at.elapsed());
1078 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1079 if resolved_ok {
1085 if let Some(path) = &pr_cache_path {
1086 persist_pr_cache(
1087 path,
1088 &pr_cache,
1089 watched.as_deref().unwrap_or(&[]),
1090 Utc::now(),
1091 );
1092 }
1093 }
1094 }
1095 });
1096 *guard = Some(PollerTask { token, handle });
1097 }
1098
1099 pub fn start_rate_limit_poller(&self) {
1117 self.start_rate_limit_poller_with(
1120 rate_limit_poll_interval(),
1121 crate::pr_status::resolve_gh_binary(),
1122 );
1123 }
1124
1125 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1130 if tokio::runtime::Handle::try_current().is_err() {
1131 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1132 return;
1133 }
1134 let mut guard = self
1135 .rate_limit_poller
1136 .lock()
1137 .unwrap_or_else(PoisonError::into_inner);
1138 if guard.is_some() {
1139 return;
1140 }
1141 let token = CancellationToken::new();
1142 let loop_token = token.clone();
1143 let cache = self.rate_limit_cache.clone();
1144 let registry = self.registry.clone();
1145 let handle = tokio::spawn(async move {
1146 let mut prev: Option<RateLimitSnapshot> = None;
1149 loop {
1150 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1158 let bin = gh_bin.clone();
1164 let resolved =
1165 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1166 .await
1167 .unwrap_or_else(|err| {
1168 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1169 });
1170 match resolved {
1171 Ok(snap) => {
1172 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1173 let summary = snap.summary_line();
1179 tracing::warn!(
1180 "GitHub API rate limit high: {summary} (querying \
1181 /rate_limit is free; the daemon's gh usage is not)"
1182 );
1183 }
1184 cache.replace(snap);
1189 prev = Some(snap);
1190 }
1191 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1196 }
1197 }
1198 tokio::select! {
1199 () = loop_token.cancelled() => break,
1200 () = tokio::time::sleep(interval) => {}
1201 }
1202 }
1203 });
1204 *guard = Some(PollerTask { token, handle });
1205 }
1206
1207 async fn close(&self, req: CloseRequest) -> Result<Value> {
1222 let entries = self.registry.list();
1226 let scan_path = req.path.clone();
1227 let open_windows =
1228 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1229 .await
1230 .unwrap_or_default();
1231 let open = !open_windows.is_empty();
1232 let window_key = open_windows.first().map(|(k, _)| k.clone());
1233 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1234
1235 if req.remove && !req.confirmed {
1239 let path = req.path.clone();
1240 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1241 .await
1242 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1243 .and_then(|inner| inner)
1244 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1245 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1251 return Ok(serde_json::to_value(SafetyReport {
1252 removable: git.removable,
1253 is_main: git.is_main,
1254 open,
1255 window_key,
1256 window_folder_count,
1257 risks: git.risks,
1258 info: git.info,
1259 })
1260 .unwrap_or_else(|_| json!({})));
1261 }
1262
1263 let others: Vec<String> = open_windows
1270 .iter()
1271 .map(|(k, _)| k.clone())
1272 .filter(|k| req.requester_key.as_deref() != Some(k))
1273 .collect();
1274 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1279 log_executing(
1280 &req.path,
1281 req.requester_key.as_deref(),
1282 req.remove,
1283 self_close,
1284 others.len(),
1285 );
1286 if !others.is_empty() {
1287 if let Err(err) = self
1288 .signal_and_await_close(
1289 &req.path,
1290 req.requester_key.as_deref(),
1291 &others,
1292 CLOSE_WAIT_TIMEOUT,
1293 CLOSE_WAIT_POLL,
1294 )
1295 .await
1296 {
1297 log_close_abort(&req.path, &err);
1298 return Err(err);
1299 }
1300 }
1301
1302 if req.remove {
1303 let path = req.path.clone();
1304 let entries = self.registry.list();
1308 let _guard = self.prune_lock.lock().await;
1314 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1315 .await
1316 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1317 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1318 log_and_map_removal(&req.path, removed)
1321 } else {
1322 log_window_closed(&req.path);
1325 Ok(json!({ "closed": true }))
1326 }
1327 }
1328
1329 async fn signal_and_await_close(
1355 &self,
1356 path: &Path,
1357 requester: Option<&str>,
1358 others: &[String],
1359 timeout: Duration,
1360 poll: Duration,
1361 ) -> Result<()> {
1362 for key in others {
1363 self.registry.mark_close_pending(key);
1364 }
1365 let waited = await_windows_closed(&self.registry, path, requester, timeout, poll).await;
1366 if waited.is_err() {
1367 for key in others {
1368 self.registry.cancel_close_pending(key);
1369 }
1370 }
1371 waited
1372 }
1373
1374 fn reload(&self, req: ReloadRequest) -> Value {
1390 let live: HashSet<String> = self
1391 .registry
1392 .list()
1393 .into_iter()
1394 .map(|entry| entry.key)
1395 .collect();
1396
1397 let mut seen = HashSet::new();
1398 let mut signalled = 0usize;
1399 let mut unknown = Vec::new();
1400 for key in &req.target_keys {
1401 if !seen.insert(key.as_str()) {
1403 continue;
1404 }
1405 if live.contains(key) {
1406 self.registry.mark_reload_pending(key);
1407 signalled += 1;
1408 } else {
1409 unknown.push(key.clone());
1410 }
1411 }
1412
1413 log_reload(seen.len(), signalled, &unknown);
1414 json!({
1415 "requested": seen.len(),
1416 "signalled": signalled,
1417 "unknown": unknown,
1418 })
1419 }
1420
1421 async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1437 self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1442 .await
1443 }
1444
1445 async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1448 let report_only = req.check || !req.confirmed;
1451
1452 let eval_bin = bin.clone();
1453 let eval_paths = req.paths.clone();
1454 let (eligible, skipped) =
1455 tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1456 .await
1457 .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1458 .and_then(|inner| inner)?;
1459
1460 if report_only {
1461 log_merge_check(&req, eligible.len(), skipped.len());
1463 let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1464 return Ok(
1465 serde_json::to_value(EligibilityReport { eligible, skipped })
1466 .unwrap_or_else(|_| json!({})),
1467 );
1468 }
1469
1470 let enqueue_bin = bin.clone();
1472 let (queued, failed) =
1473 tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1474 .await
1475 .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1476 log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1477 Ok(serde_json::to_value(EnqueueResult {
1478 queued,
1479 skipped,
1480 failed,
1481 })
1482 .unwrap_or_else(|_| json!({})))
1483 }
1484
1485 async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1511 self.rebase_with(req, crate::git::resolve_git_binary())
1515 .await
1516 }
1517
1518 async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1521 if req.paths.is_empty() {
1522 bail!("`rebase` requires at least one path");
1523 }
1524 let report_only = req.check || !req.confirmed;
1527 let opts = req.options(git_bin);
1528 let selection = Selection::Paths(req.paths.clone());
1529
1530 if report_only {
1531 let plan = plan_rebase(&selection, &opts).await?;
1534 log_rebase_check(&req, &plan);
1536 return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1537 }
1538
1539 let _guard = self.rebase_lock.lock().await;
1552 let plan = plan_rebase(&selection, &opts).await?;
1553 let pending: Vec<PathBuf> = plan
1557 .worktrees
1558 .iter()
1559 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1560 .map(|w| canonical(&w.path))
1561 .collect();
1562 self.registry.mark_rebasing(&pending);
1563 let fetches = plan.fetches.clone();
1564 let exec_opts = opts.clone();
1565 let outcomes =
1566 tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1567 self.registry.clear_rebasing(&pending);
1570 let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1571
1572 log_rebase_execute(&req, &outcomes);
1573 Ok(rebase_reply(&fetches, &outcomes))
1574 }
1575
1576 async fn push(&self, req: PushRequest) -> Result<Value> {
1605 self.push_with(req, crate::git::resolve_git_binary()).await
1608 }
1609
1610 async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1613 if req.paths.is_empty() {
1614 bail!("`push` requires at least one path");
1615 }
1616 let report_only = req.check || !req.confirmed;
1619 let selection = Selection::Paths(req.paths.clone());
1620
1621 if report_only {
1622 let plan = plan_push(&selection).await?;
1626 log_push_check(&req, &plan);
1628 return Ok(push_reply(&plan.worktrees));
1629 }
1630
1631 let _guard = self.push_lock.lock().await;
1637 let plan = plan_push(&selection).await?;
1638 let pending: Vec<PathBuf> = plan
1642 .worktrees
1643 .iter()
1644 .filter(|w| w.result.is_pending())
1645 .map(|w| canonical(&w.path))
1646 .collect();
1647 self.registry.mark_pushing(&pending);
1648 let opts = worktree_push::PushOptions {
1649 git_bin: Some(git_bin),
1650 };
1651 let outcomes =
1652 tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1653 self.registry.clear_pushing(&pending);
1657 let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1658
1659 log_push_execute(&req, &outcomes);
1660 Ok(push_reply(&outcomes))
1661 }
1662
1663 async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1681 self.reposition_with(req, geometry::ax::AxBackend::new)
1682 .await
1683 }
1684
1685 async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1694 where
1695 B: geometry::WindowBackend,
1696 F: FnOnce() -> B + Send + 'static,
1697 {
1698 if req.reference_key.trim().is_empty() {
1699 bail!("`reposition` requires a non-empty `reference_key`");
1700 }
1701 let entries = self.registry.list();
1704 let reference = registered_window(&entries, &req.reference_key);
1705 if !reference.live {
1706 bail!(
1709 "no open window with key {} (it may have closed)",
1710 req.reference_key
1711 );
1712 }
1713 let targets: Vec<geometry::RegisteredWindow> = req
1716 .target_keys
1717 .iter()
1718 .map(|key| registered_window(&entries, key))
1719 .collect();
1720
1721 let check = req.check;
1722 let mut report = tokio::task::spawn_blocking(move || {
1723 let backend = make_backend();
1726 geometry::reposition(&backend, &reference, &targets, check)
1727 })
1728 .await
1729 .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1730
1731 let undo = std::mem::take(&mut report.undo);
1736 let undoable = !check && !undo.is_empty();
1737 if undoable {
1738 *self
1739 .reposition_undo
1740 .lock()
1741 .unwrap_or_else(PoisonError::into_inner) = undo;
1742 }
1743 log_reposition(&req, &report);
1744 Ok(reposition_reply(&report, undoable))
1745 }
1746
1747 async fn reposition_undo(&self) -> Result<Value> {
1755 self.reposition_undo_with(geometry::ax::AxBackend::new)
1756 .await
1757 }
1758
1759 async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1763 where
1764 B: geometry::WindowBackend,
1765 F: FnOnce() -> B + Send + 'static,
1766 {
1767 let stored = std::mem::take(
1768 &mut *self
1769 .reposition_undo
1770 .lock()
1771 .unwrap_or_else(PoisonError::into_inner),
1772 );
1773 if stored.is_empty() {
1774 return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1775 }
1776 let entries = self.registry.list();
1777 let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1778 .into_iter()
1779 .map(|(key, frame)| (registered_window(&entries, &key), frame))
1780 .collect();
1781
1782 let report = tokio::task::spawn_blocking(move || {
1783 let backend = make_backend();
1784 geometry::restore(&backend, &restore)
1785 })
1786 .await
1787 .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1788 log_reposition_undo(&report);
1789 Ok(reposition_reply(&report, false))
1790 }
1791}
1792
1793impl Default for WorktreesService {
1794 fn default() -> Self {
1795 Self::new()
1796 }
1797}
1798
1799#[async_trait]
1800impl DaemonService for WorktreesService {
1801 fn name(&self) -> &'static str {
1802 SERVICE_NAME
1803 }
1804
1805 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1806 match op {
1807 "register" => {
1808 let req: RegisterRequest =
1809 serde_json::from_value(payload).context("invalid `register` payload")?;
1810 if req.key.trim().is_empty() {
1811 bail!("`register` requires a non-empty `key`");
1812 }
1813 self.registry.register(req);
1814 Ok(json!({ "ok": true }))
1815 }
1816 "heartbeat" => {
1817 let key = require_str(&payload, "key", "heartbeat")?;
1818 let known = self.registry.heartbeat(key);
1819 let mut reply = json!({ "known": known });
1824 if self.registry.take_close_pending(key) {
1825 reply["close"] = Value::Bool(true);
1826 }
1827 if self.registry.take_reload_pending(key) {
1834 reply["reload"] = Value::Bool(true);
1835 }
1836 Ok(reply)
1837 }
1838 "unregister" => {
1839 let key = require_str(&payload, "key", "unregister")?;
1840 Ok(json!({ "removed": self.registry.unregister(key) }))
1841 }
1842 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1843 "tree" => {
1844 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1852 }
1853 "ahead-behind" => {
1854 let paths = payload
1861 .get("paths")
1862 .and_then(Value::as_array)
1863 .map(|arr| {
1864 arr.iter()
1865 .filter_map(Value::as_str)
1866 .map(PathBuf::from)
1867 .collect::<Vec<_>>()
1868 })
1869 .unwrap_or_default();
1870 Ok(json!({ "results": ahead_behind_results(&self.ahead_behind, paths).await }))
1871 }
1872 "set-show-closed" => {
1873 let show_closed = payload
1878 .get("show_closed")
1879 .and_then(Value::as_bool)
1880 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1881 self.registry.set_show_closed(show_closed);
1882 Ok(json!({ "ok": true }))
1883 }
1884 "set-polling" => {
1885 let owner = require_str(&payload, "owner", "set-polling")?;
1894 let name = require_str(&payload, "name", "set-polling")?;
1895 let enabled = payload
1896 .get("enabled")
1897 .and_then(Value::as_bool)
1898 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1899 if owner.trim().is_empty() || name.trim().is_empty() {
1900 bail!("`set-polling` requires a non-empty `owner` and `name`");
1901 }
1902 if self.registry.set_polling(owner, name, enabled) {
1903 self.persist_polling_prefs();
1904 }
1905 Ok(json!({ "ok": true }))
1906 }
1907 "open-prs" => {
1908 let owner = require_str(&payload, "owner", "open-prs")?;
1916 let name = require_str(&payload, "name", "open-prs")?;
1917 if owner.trim().is_empty() || name.trim().is_empty() {
1918 bail!("`open-prs` requires a non-empty `owner` and `name`");
1919 }
1920 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1921 }
1922 "open" => {
1923 let path = require_str(&payload, "path", "open")?;
1932 focus_window(Path::new(path))?;
1933 Ok(json!({ "ok": true }))
1934 }
1935 "close" => {
1936 let req: CloseRequest =
1942 serde_json::from_value(payload).context("invalid `close` payload")?;
1943 self.close(req).await
1944 }
1945 "reload" => {
1946 let req: ReloadRequest =
1954 serde_json::from_value(payload).context("invalid `reload` payload")?;
1955 Ok(self.reload(req))
1956 }
1957 "merge-queue" => {
1958 let req: MergeQueueRequest =
1965 serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1966 self.merge_queue(req).await
1967 }
1968 "rebase" => {
1969 let req: RebaseRequest =
1978 serde_json::from_value(payload).context("invalid `rebase` payload")?;
1979 self.rebase(req).await
1980 }
1981 "push" => {
1982 let req: PushRequest =
1992 serde_json::from_value(payload).context("invalid `push` payload")?;
1993 self.push(req).await
1994 }
1995 "reposition" => {
1996 let req: RepositionRequest =
2004 serde_json::from_value(payload).context("invalid `reposition` payload")?;
2005 self.reposition(req).await
2006 }
2007 "reposition-undo" => {
2008 self.reposition_undo().await
2012 }
2013 other => bail!("unknown worktrees op: {other}"),
2014 }
2015 }
2016
2017 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
2018 if op != "subscribe" {
2021 return None;
2022 }
2023 Some(Box::new(WorktreesStream {
2024 cache: self.tree_cache.clone(),
2027 changes: self.registry.subscribe_changes(),
2030 }))
2031 }
2032
2033 fn menu(&self) -> MenuSnapshot {
2034 let cached = self
2039 .menu_cache
2040 .lock()
2041 .unwrap_or_else(PoisonError::into_inner)
2042 .clone();
2043 let items = cached.unwrap_or_else(|| {
2044 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
2045 });
2046 MenuSnapshot {
2047 title: SUBMENU_TITLE.to_string(),
2048 items,
2049 }
2050 }
2051
2052 async fn menu_action(&self, action_id: &str) -> Result<()> {
2053 if let Some(key) = action_id.strip_prefix("focus:") {
2054 let folder = self
2057 .registry
2058 .first_folder(key)
2059 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2060 focus_window(&folder)?;
2061 return Ok(());
2062 }
2063 bail!("unknown worktrees menu action: {action_id}")
2064 }
2065
2066 async fn status(&self) -> ServiceStatus {
2067 let entries = self.registry.list();
2068 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2069 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2070 let windows = enriched_windows(entries).await;
2071 ServiceStatus {
2072 name: SERVICE_NAME.to_string(),
2073 healthy: true,
2074 summary,
2075 detail: json!({ "windows": windows }),
2076 }
2077 }
2078
2079 fn credit_accept_outage(&self, outage: Duration) {
2080 self.registry.credit_outage(outage);
2081 }
2082
2083 async fn shutdown(&self) {
2084 let task = self
2088 .refresh
2089 .lock()
2090 .unwrap_or_else(PoisonError::into_inner)
2091 .take();
2092 if let Some(task) = task {
2093 task.token.cancel();
2094 let _ = task.handle.await;
2095 }
2096 let poller = self
2099 .poller
2100 .lock()
2101 .unwrap_or_else(PoisonError::into_inner)
2102 .take();
2103 if let Some(poller) = poller {
2104 poller.token.cancel();
2105 let _ = poller.handle.await;
2106 }
2107 let rate_limit_poller = self
2109 .rate_limit_poller
2110 .lock()
2111 .unwrap_or_else(PoisonError::into_inner)
2112 .take();
2113 if let Some(poller) = rate_limit_poller {
2114 poller.token.cancel();
2115 let _ = poller.handle.await;
2116 }
2117 }
2118}
2119
2120fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2124 payload
2125 .get(field)
2126 .and_then(Value::as_str)
2127 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2128}
2129
2130#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2141struct GitStatus {
2142 #[serde(skip_serializing_if = "Option::is_none")]
2144 branch: Option<String>,
2145 #[serde(skip_serializing_if = "Option::is_none")]
2151 head_sha: Option<String>,
2152 #[serde(skip_serializing_if = "Option::is_none")]
2160 upstream_sha: Option<String>,
2161 #[serde(skip_serializing_if = "Option::is_none")]
2163 ahead: Option<usize>,
2164 #[serde(skip_serializing_if = "Option::is_none")]
2166 behind: Option<usize>,
2167 #[serde(skip_serializing_if = "Option::is_none")]
2172 main_repo: Option<String>,
2173 #[serde(skip_serializing_if = "is_false")]
2176 is_worktree: bool,
2177 #[serde(skip_serializing_if = "Option::is_none")]
2193 operation: Option<String>,
2194}
2195
2196#[allow(clippy::trivially_copy_pass_by_ref)]
2200fn is_false(b: &bool) -> bool {
2201 !*b
2202}
2203
2204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2209struct PollingLease {
2210 repo: String,
2211 expires_at: DateTime<Utc>,
2212}
2213
2214#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2220struct PollingPrefs {
2221 #[serde(default)]
2222 enabled: Vec<PollingLease>,
2223}
2224
2225fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2229 if let Some(parent) = path.parent() {
2230 crate::daemon::paths::ensure_dir_0700(parent)?;
2231 }
2232 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2233 crate::daemon::paths::write_file_0600(path, &json)
2234}
2235
2236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2249struct PersistedBadge {
2250 number: u64,
2251 is_draft: bool,
2252 checks: PrCheckState,
2253 url: String,
2254 head_oid: String,
2255}
2256
2257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2262enum PersistedResolution {
2263 Pr(PersistedBadge),
2264 NoPr,
2265}
2266
2267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2269struct PersistedEntry {
2270 target: PrTarget,
2271 resolution: PersistedResolution,
2272}
2273
2274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2279struct PersistedWatch {
2280 target: PrTarget,
2281 #[serde(default, skip_serializing_if = "Option::is_none")]
2282 upstream_sha: Option<String>,
2283}
2284
2285#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2290struct PrCachePrefs {
2291 #[serde(default)]
2292 entries: Vec<PersistedEntry>,
2293 #[serde(default)]
2294 watched: Vec<PersistedWatch>,
2295 #[serde(default, skip_serializing_if = "Option::is_none")]
2296 polled_at: Option<DateTime<Utc>>,
2297}
2298
2299impl PersistedResolution {
2300 fn from_resolution(r: &PrResolution) -> Self {
2302 match r {
2303 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2304 number: b.number,
2305 is_draft: b.is_draft,
2306 checks: b.checks,
2307 url: b.url.clone(),
2308 head_oid: b.head_oid.clone(),
2309 }),
2310 PrResolution::NoPr => Self::NoPr,
2311 }
2312 }
2313
2314 fn into_resolution(self) -> PrResolution {
2316 match self {
2317 Self::Pr(b) => PrResolution::Pr(PrBadge {
2318 number: b.number,
2319 is_draft: b.is_draft,
2320 checks: b.checks,
2321 url: b.url,
2322 head_oid: b.head_oid,
2323 }),
2324 Self::NoPr => PrResolution::NoPr,
2325 }
2326 }
2327}
2328
2329fn pr_cache_prefs_from(
2332 entries: Vec<(PrTarget, PrResolution)>,
2333 watched: &[PrWatch],
2334 polled_at: DateTime<Utc>,
2335) -> PrCachePrefs {
2336 let mut entries: Vec<PersistedEntry> = entries
2337 .into_iter()
2338 .map(|(target, resolution)| PersistedEntry {
2339 target,
2340 resolution: PersistedResolution::from_resolution(&resolution),
2341 })
2342 .collect();
2343 entries.sort_by(|a, b| a.target.cmp(&b.target));
2346 let mut watched: Vec<PersistedWatch> = watched
2347 .iter()
2348 .map(|w| PersistedWatch {
2349 target: w.target.clone(),
2350 upstream_sha: w.upstream_sha.clone(),
2351 })
2352 .collect();
2353 watched.sort_by(|a, b| a.target.cmp(&b.target));
2354 PrCachePrefs {
2355 entries,
2356 watched,
2357 polled_at: Some(polled_at),
2358 }
2359}
2360
2361fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2364 if let Some(parent) = path.parent() {
2365 crate::daemon::paths::ensure_dir_0700(parent)?;
2366 }
2367 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2368 crate::daemon::paths::write_file_0600(path, &json)
2369}
2370
2371fn persist_pr_cache(
2377 path: &Path,
2378 pr_cache: &PrStatusCache,
2379 watched: &[PrWatch],
2380 polled_at: DateTime<Utc>,
2381) {
2382 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2383 if let Err(err) = write_pr_cache(path, &prefs) {
2384 let at = path.display();
2385 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2386 }
2387}
2388
2389#[derive(Debug, Clone)]
2395struct PrWarmStart {
2396 watched: Vec<PrWatch>,
2398 polled_at: DateTime<Utc>,
2401}
2402
2403#[derive(Debug, Clone)]
2408struct OpenPrEntry {
2409 at: Instant,
2410 prs: Vec<Value>,
2411}
2412
2413#[derive(Debug)]
2424struct OpenPrCache {
2425 entries: Mutex<HashMap<String, OpenPrEntry>>,
2426 ttl: Duration,
2427}
2428
2429impl OpenPrCache {
2430 fn new(ttl: Duration) -> Self {
2431 Self {
2432 entries: Mutex::new(HashMap::new()),
2433 ttl,
2434 }
2435 }
2436
2437 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2440 self.entries
2441 .lock()
2442 .unwrap_or_else(PoisonError::into_inner)
2443 .get(key)
2444 .filter(|e| e.at.elapsed() < self.ttl)
2445 .map(|e| e.prs.clone())
2446 }
2447
2448 fn store(&self, key: String, prs: Vec<Value>) {
2450 self.entries
2451 .lock()
2452 .unwrap_or_else(PoisonError::into_inner)
2453 .insert(
2454 key,
2455 OpenPrEntry {
2456 at: Instant::now(),
2457 prs,
2458 },
2459 );
2460 }
2461}
2462
2463fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2468 let output = crate::github_metrics::run_gh(
2469 bin,
2470 [
2471 "pr",
2472 "list",
2473 "--repo",
2474 slug,
2475 "--state",
2476 "open",
2477 "--json",
2478 OPEN_PR_JSON_FIELDS,
2479 "--limit",
2480 OPEN_PR_LIST_LIMIT,
2481 ],
2482 "pr list",
2483 None,
2484 )
2485 .with_context(|| {
2486 format!(
2487 "failed to run {} (is the GitHub CLI installed?)",
2488 bin.display()
2489 )
2490 })?;
2491 if !output.status.success() {
2492 let stderr = String::from_utf8_lossy(&output.stderr);
2493 bail!("gh pr list failed: {}", stderr.trim());
2494 }
2495 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2496 Value::Array(arr) => Ok(arr),
2497 _ => bail!("gh pr list did not return a JSON array"),
2498 }
2499}
2500
2501fn git_status(folder: &Path) -> GitStatus {
2507 git_status_impl(folder, true)
2508}
2509
2510fn git_status_cheap(folder: &Path) -> GitStatus {
2518 git_status_impl(folder, false)
2519}
2520
2521fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2527 let Ok(repo) = Repository::discover(folder) else {
2528 return GitStatus::default();
2529 };
2530 let base = GitStatus {
2536 main_repo: main_repo_name(repo.commondir()),
2537 is_worktree: repo.is_worktree(),
2538 operation: operation_slug(repo.state()),
2539 ..GitStatus::default()
2540 };
2541 let Ok(head) = repo.head() else {
2542 return base;
2544 };
2545 let base = GitStatus {
2551 head_sha: head.target().map(|oid| oid.to_string()),
2552 ..base
2553 };
2554 let Some(name) = head
2558 .shorthand()
2559 .ok()
2560 .filter(|_| head.is_branch())
2561 .map(str::to_string)
2562 else {
2563 return base;
2564 };
2565 let branch = git2::Branch::wrap(head);
2570 let upstream_sha = upstream_target(&branch);
2573 let (ahead, behind) = if with_ahead_behind {
2576 match upstream_ahead_behind(&repo, &branch) {
2577 Some((ahead, behind)) => (Some(ahead), Some(behind)),
2578 None => (None, None),
2579 }
2580 } else {
2581 (None, None)
2582 };
2583 GitStatus {
2584 branch: Some(name),
2585 upstream_sha,
2586 ahead,
2587 behind,
2588 ..base
2589 }
2590}
2591
2592fn operation_slug(state: RepositoryState) -> Option<String> {
2603 let slug = match state {
2604 RepositoryState::Clean => return None,
2605 RepositoryState::Merge => "merge",
2606 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2607 RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2608 RepositoryState::Bisect => "bisect",
2609 RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2610 RepositoryState::RebaseInteractive => "rebase-interactive",
2611 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2612 };
2613 Some(slug.to_string())
2614}
2615
2616fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2626 Some(branch.upstream().ok()?.get().target()?.to_string())
2627}
2628
2629fn head_divergence(repo: &Repository, head: Option<git2::Reference<'_>>) -> Divergence {
2645 let shallow = divergence::is_shallow(repo);
2646 let Some(head) = head.filter(git2::Reference::is_branch) else {
2647 return Divergence {
2648 shallow,
2649 ..Divergence::default()
2650 };
2651 };
2652 let branch = git2::Branch::wrap(head);
2653 Divergence {
2654 ahead_behind: upstream_ahead_behind(repo, &branch),
2655 main_behind: repo_main_behind(repo, &branch),
2656 shallow,
2657 }
2658}
2659
2660fn repo_main_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<usize> {
2673 let (onto_ref, onto_oid) = default_branch_tip(repo)?;
2674
2675 if let Ok(upstream) = branch.upstream() {
2680 if upstream.get().name() == Ok(onto_ref.as_str()) {
2681 return None;
2682 }
2683 }
2684
2685 let head_oid = branch.get().target()?;
2686 let (_ahead, behind) = divergence::graph_ahead_behind(repo, head_oid, onto_oid)?;
2687 Some(behind)
2688}
2689
2690fn default_branch_tip(repo: &Repository) -> Option<(String, git2::Oid)> {
2701 let remote = "origin";
2702 let default_branch = RemoteInfo::detect_main_branch_local(repo, remote)?;
2703 let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2704 let oid = repo
2705 .find_reference(&onto_ref)
2706 .ok()?
2707 .resolve()
2708 .ok()?
2709 .target()?;
2710 Some((onto_ref, oid))
2711}
2712
2713fn folder_divergence(folder: &Path) -> Divergence {
2721 let Ok(repo) = Repository::discover(folder) else {
2722 return Divergence::default();
2723 };
2724 head_divergence(&repo, repo.head().ok())
2725}
2726
2727fn folder_divergence_shared(pool: &shared_repo::RepoPool, folder: &Path) -> Divergence {
2733 shared_repo::divergence(pool, folder).unwrap_or_else(|| folder_divergence(folder))
2734}
2735
2736fn main_repo_name(commondir: &Path) -> Option<String> {
2742 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2743 if file_name == ".git" {
2744 commondir
2746 .parent()
2747 .and_then(Path::file_name)
2748 .map(|n| n.to_string_lossy().into_owned())
2749 } else {
2750 Some(
2752 file_name
2753 .strip_suffix(".git")
2754 .unwrap_or(&file_name)
2755 .to_string(),
2756 )
2757 }
2758}
2759
2760fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2763 let upstream = branch.upstream().ok()?;
2764 let local_oid = branch.get().target()?;
2765 let upstream_oid = upstream.get().target()?;
2766 divergence::graph_ahead_behind(repo, local_oid, upstream_oid)
2767}
2768
2769#[derive(Serialize)]
2775struct EnrichedEntry<'a> {
2776 #[serde(flatten)]
2777 entry: &'a WindowEntry,
2778 #[serde(flatten)]
2779 git: GitStatus,
2780}
2781
2782fn enriched_entry(entry: &WindowEntry) -> Value {
2787 let git = entry
2788 .folders
2789 .first()
2790 .map(|folder| git_status(folder))
2791 .unwrap_or_default();
2792 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2793}
2794
2795async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2799 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2800 .await
2801 .unwrap_or_default()
2802}
2803
2804#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2810struct GithubIdentity {
2811 owner: String,
2813 name: String,
2815}
2816
2817#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2833struct TreeWorktree {
2834 path: String,
2836 #[serde(skip_serializing_if = "Option::is_none")]
2838 branch: Option<String>,
2839 #[serde(skip_serializing_if = "Option::is_none")]
2844 head_sha: Option<String>,
2845 #[serde(skip_serializing_if = "Option::is_none")]
2851 upstream_sha: Option<String>,
2852 is_main: bool,
2854 open: bool,
2856 #[serde(skip_serializing_if = "Option::is_none")]
2859 window_key: Option<String>,
2860 #[serde(skip_serializing_if = "Option::is_none")]
2866 pr: Option<PrBadge>,
2867 #[serde(skip_serializing_if = "is_false")]
2877 pr_none: bool,
2878 #[serde(skip_serializing_if = "Option::is_none")]
2883 operation: Option<String>,
2884 #[serde(skip_serializing_if = "is_false")]
2893 rebasing: bool,
2894 #[serde(skip_serializing_if = "is_false")]
2903 pushing: bool,
2904}
2905
2906#[derive(Debug, Clone, Default)]
2914struct InFlight {
2915 rebasing: HashSet<PathBuf>,
2917 pushing: HashSet<PathBuf>,
2919}
2920
2921impl InFlight {
2922 fn read(registry: &WorktreesRegistry) -> Self {
2926 Self {
2927 rebasing: registry.rebasing_paths(),
2928 pushing: registry.pushing_paths(),
2929 }
2930 }
2931}
2932
2933#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2937struct TreeRepo {
2938 main_repo: String,
2940 #[serde(skip_serializing_if = "Option::is_none")]
2942 github: Option<GithubIdentity>,
2943 root: String,
2945 #[serde(skip_serializing_if = "is_false")]
2953 polling_enabled: bool,
2954 #[serde(skip_serializing_if = "Option::is_none")]
2968 main_sha: Option<String>,
2969 worktrees: Vec<TreeWorktree>,
2972}
2973
2974fn github_identity(url: &str) -> Option<GithubIdentity> {
2981 let url = url.trim();
2982 let rest = [
2984 "https://github.com/",
2985 "http://github.com/",
2986 "ssh://git@github.com/",
2987 "git://github.com/",
2988 "git@github.com:",
2989 ]
2990 .iter()
2991 .find_map(|prefix| url.strip_prefix(prefix))?;
2992 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2993 let rest = rest.trim_end_matches('/');
2994 let mut parts = rest.splitn(2, '/');
2995 let owner = parts.next()?.trim();
2996 let name = parts.next()?.trim();
2997 if owner.is_empty() || name.is_empty() || name.contains('/') {
2999 return None;
3000 }
3001 Some(GithubIdentity {
3002 owner: owner.to_string(),
3003 name: name.to_string(),
3004 })
3005}
3006
3007fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
3010 if let Ok(origin) = repo.find_remote("origin") {
3011 if let Some(id) = origin.url().ok().and_then(github_identity) {
3012 return Some(id);
3013 }
3014 }
3015 let names = repo.remotes().ok();
3019 names
3020 .iter()
3021 .flat_map(|arr| arr.iter())
3022 .flatten()
3023 .flatten()
3024 .filter_map(|name| repo.find_remote(name).ok())
3025 .find_map(|remote| remote.url().ok().and_then(github_identity))
3026}
3027
3028fn canonical(path: &Path) -> PathBuf {
3032 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3033}
3034
3035fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
3040 let mut index = HashMap::new();
3041 for entry in entries {
3042 for folder in &entry.folders {
3043 index
3044 .entry(canonical(folder))
3045 .or_insert_with(|| entry.key.clone());
3046 }
3047 }
3048 index
3049}
3050
3051fn worktree_entry(
3056 path: &Path,
3057 is_main: bool,
3058 open_index: &HashMap<PathBuf, String>,
3059 in_flight: &InFlight,
3060) -> TreeWorktree {
3061 let status = git_status_cheap(path);
3062 let canonical = canonical(path);
3063 let window_key = open_index.get(&canonical).cloned();
3064 TreeWorktree {
3065 path: path.display().to_string(),
3066 branch: status.branch,
3067 head_sha: status.head_sha,
3068 upstream_sha: status.upstream_sha,
3069 is_main,
3070 open: window_key.is_some(),
3071 window_key,
3072 pr: None,
3075 pr_none: false,
3076 operation: status.operation,
3077 rebasing: in_flight.rebasing.contains(&canonical),
3080 pushing: in_flight.pushing.contains(&canonical),
3081 }
3082}
3083
3084fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3113 for repo in repos {
3114 if let Some(github) = &repo.github {
3115 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3116 }
3117 }
3118}
3119
3120fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3121 for repo in repos {
3122 if !repo.polling_enabled {
3126 continue;
3127 }
3128 let Some(github) = repo.github.clone() else {
3129 continue;
3130 };
3131 for worktree in &mut repo.worktrees {
3132 let Some(branch) = &worktree.branch else {
3133 continue;
3134 };
3135 match pr_cache.get(&github.owner, &github.name, branch) {
3136 Some(PrResolution::Pr(mut badge)) => {
3137 if badge.is_stale_for(worktree.head_sha.as_deref()) {
3138 badge.checks = PrCheckState::Pending;
3139 }
3140 worktree.pr = Some(badge);
3141 }
3142 Some(PrResolution::NoPr) => worktree.pr_none = true,
3143 None => {}
3144 }
3145 }
3146 }
3147}
3148
3149fn repo_tree(
3155 discovered: &Repository,
3156 open_index: &HashMap<PathBuf, String>,
3157 in_flight: &InFlight,
3158) -> Option<TreeRepo> {
3159 let commondir = canonical(discovered.commondir());
3162 let main_root = commondir.parent()?.to_path_buf();
3163 let main_repo = Repository::open(&main_root).ok()?;
3164
3165 let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3167 let names = main_repo.worktrees().ok();
3172 let mut linked: Vec<PathBuf> = names
3173 .iter()
3174 .flat_map(|arr| arr.iter())
3175 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
3178 .map(|wt| wt.path().to_path_buf())
3179 .collect();
3180 linked.sort();
3181 worktrees.extend(
3182 linked
3183 .iter()
3184 .map(|path| worktree_entry(path, false, open_index, in_flight)),
3185 );
3186
3187 Some(TreeRepo {
3188 main_repo: main_repo_name(&commondir)?,
3189 github: remote_github_identity(&main_repo),
3190 root: main_root.display().to_string(),
3191 polling_enabled: false,
3194 main_sha: default_branch_tip(&main_repo).map(|(_, oid)| oid.to_string()),
3195 worktrees,
3196 })
3197}
3198
3199fn build_tree(
3205 folders: Vec<PathBuf>,
3206 windows: Vec<WindowEntry>,
3207 in_flight: InFlight,
3208) -> Vec<TreeRepo> {
3209 let open_index = open_window_index(&windows);
3210 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3211 for folder in &folders {
3212 let Ok(repo) = Repository::discover(folder) else {
3213 continue;
3214 };
3215 let key = canonical(repo.commondir());
3216 if repos.contains_key(&key) {
3217 continue;
3218 }
3219 if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3220 repos.insert(key, tree);
3221 }
3222 }
3223 repos.into_values().collect()
3224}
3225
3226async fn tree_repos(
3231 folders: Vec<PathBuf>,
3232 windows: Vec<WindowEntry>,
3233 pr_cache: Arc<PrStatusCache>,
3234 enabled_polling: HashSet<String>,
3235 in_flight: InFlight,
3236) -> Vec<Value> {
3237 tokio::task::spawn_blocking(move || {
3238 let mut repos = build_tree(folders, windows, in_flight);
3239 stamp_polling(&mut repos, &enabled_polling);
3242 fold_pr_badges(&mut repos, &pr_cache);
3243 repos
3244 .iter()
3245 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3246 .collect()
3247 })
3248 .await
3249 .unwrap_or_default()
3250}
3251
3252#[derive(Serialize)]
3261struct AheadBehindEntry {
3262 #[serde(skip_serializing_if = "Option::is_none")]
3263 ahead: Option<usize>,
3264 #[serde(skip_serializing_if = "Option::is_none")]
3265 behind: Option<usize>,
3266 #[serde(skip_serializing_if = "Option::is_none")]
3267 main_behind: Option<usize>,
3268 #[serde(skip_serializing_if = "is_false")]
3275 shallow: bool,
3276}
3277
3278async fn ahead_behind_results(coordinator: &AheadBehindCoordinator, paths: Vec<PathBuf>) -> Value {
3298 let pool = coordinator.pool();
3302 let divergences = futures::future::join_all(paths.iter().map(|path| {
3303 let pool = Arc::clone(&pool);
3304 coordinator.get_or_compute(path.clone(), move |p| folder_divergence_shared(&pool, &p))
3305 }))
3306 .await;
3307 let mut results = serde_json::Map::new();
3308 for (path, divergence) in paths.iter().zip(divergences) {
3309 let (ahead, behind) = divergence
3310 .ahead_behind
3311 .map_or((None, None), |(a, b)| (Some(a), Some(b)));
3312 let main_behind = divergence.main_behind;
3313 if ahead.is_none() && main_behind.is_none() {
3314 continue;
3315 }
3316 results.insert(
3317 path.display().to_string(),
3318 json!(AheadBehindEntry {
3319 ahead,
3320 behind,
3321 main_behind,
3322 shallow: divergence.shallow,
3323 }),
3324 );
3325 }
3326 Value::Object(results)
3327}
3328
3329struct WorktreesStream {
3343 cache: Arc<TreeSnapshotCache>,
3346 changes: watch::Receiver<u64>,
3350}
3351
3352#[async_trait]
3353impl ServiceStream for WorktreesStream {
3354 async fn changed(&mut self) {
3355 if self.changes.changed().await.is_err() {
3361 std::future::pending::<()>().await;
3362 }
3363 }
3364
3365 async fn snapshot(&self) -> Value {
3366 self.cache.snapshot().await
3371 }
3372}
3373
3374struct TreeSnapshotCache {
3398 registry: Arc<WorktreesRegistry>,
3401 pr_cache: Arc<PrStatusCache>,
3404 ttl: Duration,
3408 state: AsyncMutex<Option<CachedTree>>,
3413 computes: AtomicU64,
3417}
3418
3419struct CachedTree {
3422 generation: u64,
3426 computed_at: Instant,
3428 value: Arc<Value>,
3431}
3432
3433impl TreeSnapshotCache {
3434 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3438 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3439 }
3440
3441 fn with_ttl(
3444 registry: Arc<WorktreesRegistry>,
3445 pr_cache: Arc<PrStatusCache>,
3446 ttl: Duration,
3447 ) -> Self {
3448 Self {
3449 registry,
3450 pr_cache,
3451 ttl,
3452 state: AsyncMutex::new(None),
3453 computes: AtomicU64::new(0),
3454 }
3455 }
3456
3457 async fn snapshot(&self) -> Value {
3461 let mut state = self.state.lock().await;
3466 let generation = self.registry.change_generation();
3467 let fresh = state.as_ref().and_then(|cached| {
3470 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3471 .then(|| Arc::clone(&cached.value))
3472 });
3473 let value = if let Some(value) = fresh {
3474 value
3475 } else {
3476 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3477 self.computes.fetch_add(1, Ordering::Relaxed);
3478 *state = Some(CachedTree {
3479 generation,
3480 computed_at: Instant::now(),
3481 value: Arc::clone(&value),
3482 });
3483 value
3484 };
3485 drop(state);
3487 (*value).clone()
3488 }
3489
3490 #[cfg(test)]
3493 fn compute_count(&self) -> u64 {
3494 self.computes.load(Ordering::Relaxed)
3495 }
3496}
3497
3498async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3504 let folders = registry.open_folders();
3505 let windows = registry.list();
3506 let show_closed = registry.show_closed();
3507 let enabled_polling = registry.enabled_polling_repos();
3508 let in_flight = InFlight::read(registry);
3511 json!({
3512 "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3513 "show_closed": show_closed,
3514 })
3515}
3516
3517fn display_name(entry: &WindowEntry) -> String {
3520 if let Some(repo) = &entry.repo {
3521 return repo.clone();
3522 }
3523 if let Some(folder) = entry.folders.first() {
3524 return folder.file_name().map_or_else(
3525 || folder.display().to_string(),
3526 |n| n.to_string_lossy().into_owned(),
3527 );
3528 }
3529 "(no folder)".to_string()
3530}
3531
3532const REPO_SEP: char = '·';
3534const WORKTREE_SEP: char = '⑂';
3537
3538fn menu_items_for(
3543 entries: &[WindowEntry],
3544 rate_limit: Option<&RateLimitSnapshot>,
3545) -> Vec<MenuItem> {
3546 let mut items = Vec::new();
3547 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3551 if !label.is_empty() {
3552 items.push(MenuItem::Label(label));
3553 items.push(MenuItem::Separator);
3554 }
3555 }
3556 if entries.is_empty() {
3557 items.push(MenuItem::Label("No open windows".to_string()));
3558 } else {
3559 items.extend(window_menu_items(entries));
3560 }
3561 items
3562}
3563
3564fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3571 entries
3572 .iter()
3573 .map(|entry| {
3574 let label = window_label(entry);
3575 if entry.folders.is_empty() {
3576 MenuItem::Label(label)
3577 } else {
3578 MenuItem::Action(MenuAction {
3579 id: format!("focus:{}", entry.key),
3580 label,
3581 enabled: true,
3582 })
3583 }
3584 })
3585 .collect()
3586}
3587
3588fn window_label(entry: &WindowEntry) -> String {
3594 let status = entry
3595 .folders
3596 .first()
3597 .map(|folder| git_status(folder))
3598 .unwrap_or_default();
3599 let name = status
3602 .main_repo
3603 .clone()
3604 .unwrap_or_else(|| display_name(entry));
3605 if let Some(branch) = &status.branch {
3606 let sep = if status.is_worktree {
3607 WORKTREE_SEP
3608 } else {
3609 REPO_SEP
3610 };
3611 return match sync_indicator(status.ahead, status.behind) {
3612 Some(sync) => format!("{name} {sep} {branch} {sync}"),
3613 None => format!("{name} {sep} {branch}"),
3614 };
3615 }
3616 match &entry.title {
3618 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3619 _ => name,
3620 }
3621}
3622
3623fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3626 match (ahead, behind) {
3627 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3628 _ => None,
3629 }
3630}
3631
3632const CODE_BINARY_CANDIDATES: &[&str] = &[
3635 "/usr/local/bin/code",
3636 "/opt/homebrew/bin/code",
3637 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3638 "/usr/bin/code",
3639];
3640
3641pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3646 focus_window_with(&resolve_code_binary(), folder)
3647}
3648
3649fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3656 if !folder.is_absolute() {
3661 bail!(
3662 "refusing to focus a non-absolute folder path: {}",
3663 folder.display()
3664 );
3665 }
3666 if !folder.is_dir() {
3667 bail!("worktree folder no longer exists: {}", folder.display());
3668 }
3669 let child = Command::new(program)
3672 .arg(folder)
3673 .stdin(Stdio::null())
3674 .stdout(Stdio::null())
3675 .stderr(Stdio::null())
3676 .spawn()
3677 .with_context(|| {
3678 format!(
3679 "failed to launch `{}` to focus {}",
3680 program.display(),
3681 folder.display()
3682 )
3683 })?;
3684 std::thread::spawn(move || {
3686 let mut child = child;
3687 let _ = child.wait();
3688 });
3689 Ok(())
3690}
3691
3692fn resolve_code_binary() -> PathBuf {
3697 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3698}
3699
3700fn resolve_code_binary_from(
3703 env_override: Option<std::ffi::OsString>,
3704 candidates: &[&str],
3705) -> PathBuf {
3706 if let Some(path) = env_override {
3707 return PathBuf::from(path);
3708 }
3709 for candidate in candidates {
3710 let path = Path::new(candidate);
3711 if path.exists() {
3712 return path.to_path_buf();
3713 }
3714 }
3715 PathBuf::from("code")
3716}
3717
3718#[derive(Debug, Clone, Deserialize)]
3728struct RepositionRequest {
3729 reference_key: String,
3731 #[serde(default)]
3734 target_keys: Vec<String>,
3735 #[serde(default)]
3738 check: bool,
3739}
3740
3741fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3748 entries.iter().find(|entry| entry.key == key).map_or_else(
3749 || geometry::RegisteredWindow {
3750 key: key.to_string(),
3751 live: false,
3752 title: None,
3753 pid: None,
3754 },
3755 |entry| geometry::RegisteredWindow {
3756 key: entry.key.clone(),
3757 live: true,
3758 title: entry.title.clone(),
3759 pid: entry.pid,
3760 },
3761 )
3762}
3763
3764fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3770 let mut reply = json!({
3771 "trusted": report.trusted,
3772 "results": report.results,
3773 "moved": report.moved(),
3774 "skipped": report.skipped(),
3775 });
3776 if let Some(reference) = &report.reference {
3777 reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3778 }
3779 if let Some(blocked) = &report.blocked {
3780 reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3781 }
3782 if undoable {
3785 reply["undoable"] = Value::Bool(true);
3786 }
3787 reply
3788}
3789
3790fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3794 let phase = if !report.trusted {
3797 "untrusted"
3798 } else if report.blocked.is_some() {
3799 "blocked"
3800 } else if req.check {
3801 "check"
3802 } else {
3803 "apply"
3804 };
3805 tracing::info!(
3806 phase,
3807 reference = req.reference_key.as_str(),
3808 requested = req.target_keys.len(),
3809 blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3810 moved = report.moved(),
3811 skipped = report.skipped(),
3812 outcomes = outcome_kinds(report).as_str(),
3813 "reposition"
3814 );
3815}
3816
3817fn log_reposition_undo(report: &geometry::RepositionReport) {
3819 tracing::info!(
3820 trusted = report.trusted,
3821 restored = report.moved(),
3822 skipped = report.skipped(),
3823 outcomes = outcome_kinds(report).as_str(),
3824 "reposition undo"
3825 );
3826}
3827
3828fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3832 if report.results.is_empty() {
3833 return "-".to_string();
3834 }
3835 report
3836 .results
3837 .iter()
3838 .map(|r| r.outcome)
3839 .collect::<Vec<_>>()
3840 .join(",")
3841}
3842
3843#[derive(Debug, Clone, Deserialize)]
3853struct ReloadRequest {
3854 #[serde(default)]
3858 target_keys: Vec<String>,
3859}
3860
3861fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3865 let unknown = if unknown.is_empty() {
3869 "-".to_string()
3870 } else {
3871 unknown.join(",")
3872 };
3873 tracing::info!(
3874 requested,
3875 signalled,
3876 unknown = %unknown,
3877 "worktrees reload: signalled windows"
3878 );
3879}
3880
3881#[derive(Debug, Clone, Deserialize)]
3887struct CloseRequest {
3888 path: PathBuf,
3890 #[serde(default)]
3894 requester_key: Option<String>,
3895 #[serde(default)]
3899 remove: bool,
3900 #[serde(default)]
3903 confirmed: bool,
3904}
3905
3906#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3911struct Note {
3912 kind: String,
3914 detail: String,
3916}
3917
3918impl Note {
3919 fn new(kind: &str, detail: impl Into<String>) -> Self {
3920 Self {
3921 kind: kind.to_string(),
3922 detail: detail.into(),
3923 }
3924 }
3925}
3926
3927fn note_kinds(notes: &[Note]) -> String {
3931 if notes.is_empty() {
3932 return "-".to_string();
3933 }
3934 notes
3935 .iter()
3936 .map(|n| n.kind.as_str())
3937 .collect::<Vec<_>>()
3938 .join(",")
3939}
3940
3941fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3946 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3947}
3948
3949fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3955 tracing::error!(
3956 path = %path.display(),
3957 "worktrees close: {phase} failed: {err:#}"
3958 );
3959 err
3960}
3961
3962fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3973 match removed {
3974 Ok(Removal::Pruned) => {
3975 tracing::info!(
3976 path = %path.display(),
3977 outcome = "pruned",
3978 "worktrees close: linked worktree pruned"
3979 );
3980 Ok(json!({ "removed": true }))
3981 }
3982 Ok(Removal::AlreadyGone) => {
3983 tracing::info!(
3984 path = %path.display(),
3985 outcome = "already-gone",
3986 "worktrees close: nothing to prune, worktree already removed"
3987 );
3988 Ok(json!({ "removed": true }))
3989 }
3990 Err(err) => {
3991 tracing::warn!(
3992 path = %path.display(),
3993 outcome = "failed",
3994 "worktrees close: worktree prune failed: {err:#}"
3995 );
3996 Err(err)
3997 }
3998 }
3999}
4000
4001fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
4007 tracing::info!(
4008 path = %path.display(),
4009 window_key = window_key.unwrap_or("-"),
4010 removable = git.removable,
4011 is_main = git.is_main,
4012 open,
4013 risks = %note_kinds(&git.risks),
4014 "worktrees close: safety check"
4015 );
4016}
4017
4018fn log_executing(
4023 path: &Path,
4024 requester: Option<&str>,
4025 remove: bool,
4026 self_close: bool,
4027 cross_window: usize,
4028) {
4029 tracing::info!(
4030 path = %path.display(),
4031 requester = requester.unwrap_or("-"),
4032 remove,
4033 self_close,
4034 cross_window,
4035 "worktrees close: executing"
4036 );
4037}
4038
4039fn log_close_abort(path: &Path, err: &anyhow::Error) {
4043 tracing::warn!(
4044 path = %path.display(),
4045 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
4046 );
4047}
4048
4049fn log_window_closed(path: &Path) {
4053 tracing::info!(
4054 path = %path.display(),
4055 "worktrees close: window closed, no removal"
4056 );
4057}
4058
4059#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4063struct SafetyReport {
4064 removable: bool,
4067 is_main: bool,
4069 open: bool,
4071 #[serde(skip_serializing_if = "Option::is_none")]
4073 window_key: Option<String>,
4074 window_folder_count: usize,
4077 risks: Vec<Note>,
4080 info: Vec<Note>,
4083}
4084
4085#[derive(Debug, Clone, PartialEq, Eq)]
4088struct GitSafety {
4089 is_main: bool,
4090 removable: bool,
4091 risks: Vec<Note>,
4092 info: Vec<Note>,
4093}
4094
4095#[derive(Debug, Clone, Deserialize)]
4103struct RebaseRequest {
4104 paths: Vec<PathBuf>,
4106 #[serde(default)]
4109 requester_key: Option<String>,
4110 #[serde(default)]
4112 check: bool,
4113 #[serde(default)]
4115 confirmed: bool,
4116 #[serde(default)]
4121 keep_conflicts: bool,
4122 #[serde(default)]
4125 autostash: bool,
4126 #[serde(default)]
4128 onto: Option<String>,
4129}
4130
4131impl RebaseRequest {
4132 fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4134 worktree_rebase::RebaseOptions {
4135 onto: self.onto.clone(),
4136 autostash: self.autostash,
4137 dry_run: false,
4140 keep_conflicts: self.keep_conflicts,
4141 git_bin: Some(git_bin),
4142 }
4143 }
4144}
4145
4146async fn plan_rebase(
4150 selection: &Selection,
4151 opts: &worktree_rebase::RebaseOptions,
4152) -> Result<worktree_rebase::Plan> {
4153 let selection = selection.clone();
4154 let opts = opts.clone();
4155 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4156 .await
4157 .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4158 .and_then(|inner| inner)
4159}
4160
4161fn rebase_reply(
4166 fetches: &[worktree_rebase::FetchOutcome],
4167 worktrees: &[worktree_rebase::WorktreeOutcome],
4168) -> Value {
4169 json!({ "fetches": fetches, "worktrees": worktrees })
4170}
4171
4172fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4176 let pending = plan
4177 .worktrees
4178 .iter()
4179 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4180 .count();
4181 let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4182 tracing::info!(
4183 requester = req.requester_key.as_deref().unwrap_or("-"),
4184 requested = req.paths.len(),
4185 pending,
4186 fetches = plan.fetches.len(),
4187 failed_fetches,
4188 "rebase check"
4189 );
4190}
4191
4192fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4196 use worktree_rebase::RebaseResult;
4197 let mut rebased = 0;
4198 let mut conflicts = 0;
4199 let mut left_in_place = 0;
4200 let mut skipped = 0;
4201 for outcome in outcomes {
4202 match &outcome.result {
4203 RebaseResult::Rebased { .. } => rebased += 1,
4204 RebaseResult::Conflict {
4205 left_in_place: k, ..
4206 } => {
4207 conflicts += 1;
4208 if *k {
4209 left_in_place += 1;
4210 }
4211 }
4212 RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4213 RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4214 }
4215 }
4216 tracing::info!(
4217 requester = req.requester_key.as_deref().unwrap_or("-"),
4218 requested = req.paths.len(),
4219 rebased,
4220 conflicts,
4221 left_in_place,
4222 skipped,
4223 "rebase execute"
4224 );
4225}
4226
4227#[derive(Debug, Clone, Deserialize)]
4239struct PushRequest {
4240 paths: Vec<PathBuf>,
4242 #[serde(default)]
4245 requester_key: Option<String>,
4246 #[serde(default)]
4248 check: bool,
4249 #[serde(default)]
4251 confirmed: bool,
4252}
4253
4254async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4258 let selection = selection.clone();
4259 tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4260 .await
4261 .map_err(|e| anyhow!("push planning task panicked: {e}"))
4262 .and_then(|inner| inner)
4263}
4264
4265fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4273 json!({ "worktrees": worktrees })
4274}
4275
4276fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4280 use worktree_push::PushResult;
4281 let pending = plan
4282 .worktrees
4283 .iter()
4284 .filter(|w| w.result.is_pending())
4285 .count();
4286 let forced = plan
4287 .worktrees
4288 .iter()
4289 .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4290 .count();
4291 let skipped = plan
4292 .worktrees
4293 .iter()
4294 .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4295 .count();
4296 tracing::info!(
4297 requester = req.requester_key.as_deref().unwrap_or("-"),
4298 requested = req.paths.len(),
4299 pending,
4300 forced,
4301 skipped,
4302 "push check"
4303 );
4304}
4305
4306fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4311 use worktree_push::PushResult;
4312 let mut pushed = 0;
4313 let mut forced = 0;
4314 let mut created = 0;
4315 let mut rejected = 0;
4316 let mut stale_rejected = 0;
4317 for outcome in outcomes {
4318 match &outcome.result {
4319 PushResult::Pushed { forced: f } => {
4320 pushed += 1;
4321 if *f {
4322 forced += 1;
4323 }
4324 }
4325 PushResult::Created => created += 1,
4326 PushResult::Rejected { stale, .. } => {
4327 rejected += 1;
4328 if *stale {
4329 stale_rejected += 1;
4330 }
4331 }
4332 PushResult::UpToDate
4333 | PushResult::WouldFastForward { .. }
4334 | PushResult::WouldForce { .. }
4335 | PushResult::WouldCreate
4336 | PushResult::Skipped { .. } => {}
4337 }
4338 }
4339 tracing::info!(
4340 requester = req.requester_key.as_deref().unwrap_or("-"),
4341 requested = req.paths.len(),
4342 pushed,
4343 forced,
4344 created,
4345 rejected,
4346 stale_rejected,
4347 "push execute"
4348 );
4349}
4350
4351#[derive(Debug, Clone, Deserialize)]
4357struct MergeQueueRequest {
4358 paths: Vec<PathBuf>,
4360 #[serde(default)]
4363 requester_key: Option<String>,
4364 #[serde(default)]
4366 check: bool,
4367 #[serde(default)]
4369 confirmed: bool,
4370}
4371
4372#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4374struct PrRef {
4375 path: String,
4377 number: u64,
4379 url: String,
4381 branch: String,
4383}
4384
4385impl From<&Eligible> for PrRef {
4386 fn from(e: &Eligible) -> Self {
4387 Self {
4388 path: e.path.to_string_lossy().to_string(),
4389 number: e.number,
4390 url: e.url.clone(),
4391 branch: e.branch.clone(),
4392 }
4393 }
4394}
4395
4396#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4399struct Skip {
4400 path: String,
4401 kind: String,
4402 detail: String,
4403}
4404
4405impl Skip {
4406 fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4407 Self {
4408 path: path.to_string_lossy().to_string(),
4409 kind: kind.to_string(),
4410 detail: detail.into(),
4411 }
4412 }
4413}
4414
4415#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4419struct EligibilityReport {
4420 eligible: Vec<PrRef>,
4421 skipped: Vec<Skip>,
4422}
4423
4424#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4426struct QueuedPr {
4427 path: String,
4428 number: u64,
4429 #[serde(skip_serializing_if = "is_false")]
4432 already_queued: bool,
4433}
4434
4435#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4438struct EnqueueFailure {
4439 path: String,
4440 number: u64,
4441 error: String,
4442}
4443
4444#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4447struct EnqueueResult {
4448 queued: Vec<QueuedPr>,
4449 skipped: Vec<Skip>,
4450 failed: Vec<EnqueueFailure>,
4451}
4452
4453#[derive(Debug)]
4456struct LocalOk {
4457 path: PathBuf,
4458 target: PrTarget,
4459 head_sha: String,
4460}
4461
4462#[derive(Debug)]
4464struct Eligible {
4465 path: PathBuf,
4466 number: u64,
4467 url: String,
4468 branch: String,
4469 pr_id: String,
4471 already_queued: bool,
4473}
4474
4475fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4481 let Ok(repo) = Repository::discover(path) else {
4482 return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4483 };
4484 let (dirty, untracked) = count_dirty_untracked(&repo);
4486 if dirty > 0 {
4487 return Err(Skip::new(
4488 path,
4489 "dirty",
4490 format!("{dirty} modified tracked file(s) — commit or stash first"),
4491 ));
4492 }
4493 if untracked > 0 {
4494 return Err(Skip::new(
4495 path,
4496 "untracked",
4497 format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4498 ));
4499 }
4500 let Ok(head) = repo.head() else {
4503 return Err(Skip::new(
4504 path,
4505 "no-commits",
4506 "the branch has no commits yet",
4507 ));
4508 };
4509 let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4510 return Err(Skip::new(
4511 path,
4512 "no-commits",
4513 "HEAD does not resolve to a commit",
4514 ));
4515 };
4516 let Some(branch_name) = head
4519 .shorthand()
4520 .ok()
4521 .filter(|_| head.is_branch())
4522 .map(str::to_string)
4523 else {
4524 return Err(Skip::new(
4525 path,
4526 "detached",
4527 "HEAD is detached — no branch to enqueue",
4528 ));
4529 };
4530 let branch = git2::Branch::wrap(head);
4531 let Some(upstream_sha) = upstream_target(&branch) else {
4533 return Err(Skip::new(
4534 path,
4535 "no-upstream",
4536 "the branch tracks no upstream — push it first",
4537 ));
4538 };
4539 if upstream_sha != head_sha {
4540 return Err(Skip::new(
4541 path,
4542 "unpushed",
4543 "local commits are not on the remote yet — push first",
4544 ));
4545 }
4546 if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4549 if ahead > 0 {
4550 return Err(Skip::new(
4551 path,
4552 "unpushed",
4553 format!("{ahead} unpushed commit(s) — push first"),
4554 ));
4555 }
4556 }
4557 let Some(id) = remote_github_identity(&repo) else {
4560 return Err(Skip::new(
4561 path,
4562 "no-github",
4563 "the repository has no github.com remote",
4564 ));
4565 };
4566 Ok(LocalOk {
4567 path: path.to_path_buf(),
4568 target: PrTarget {
4569 owner: id.owner,
4570 name: id.name,
4571 branch: branch_name,
4572 },
4573 head_sha,
4574 })
4575}
4576
4577fn is_conflicting(state: Option<&str>) -> bool {
4582 matches!(state, Some("CONFLICTING" | "DIRTY"))
4583}
4584
4585fn check_label(state: PrCheckState) -> &'static str {
4587 match state {
4588 PrCheckState::Success => "passing",
4589 PrCheckState::Failure => "failing",
4590 PrCheckState::Pending => "still running",
4591 PrCheckState::None => "not reported",
4592 }
4593}
4594
4595fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4600 tracing::info!(
4601 requester = req.requester_key.as_deref().unwrap_or("-"),
4602 requested = req.paths.len(),
4603 eligible,
4604 skipped,
4605 "merge-queue check"
4606 );
4607}
4608
4609fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4613 tracing::info!(
4614 requester = req.requester_key.as_deref().unwrap_or("-"),
4615 queued,
4616 failed,
4617 skipped,
4618 "merge-queue enqueue"
4619 );
4620}
4621
4622fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4633 let mut skipped = Vec::new();
4634 let mut locals = Vec::new();
4635 for path in paths {
4636 match evaluate_local(path) {
4637 Ok(ok) => locals.push(ok),
4638 Err(skip) => skipped.push(skip),
4639 }
4640 }
4641 if locals.is_empty() {
4642 return Ok((Vec::new(), skipped));
4643 }
4644 let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4645 let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4646 let mut eligible = Vec::new();
4647 for local in locals {
4648 let Some(info) = resolved.get(&local.target) else {
4649 skipped.push(Skip::new(
4650 &local.path,
4651 "no-pr",
4652 "no open PR heads this branch",
4653 ));
4654 continue;
4655 };
4656 if info.head_oid != local.head_sha {
4657 skipped.push(Skip::new(
4658 &local.path,
4659 "stale",
4660 "the open PR's head differs from the local head — re-check",
4661 ));
4662 } else if info.is_draft {
4663 skipped.push(Skip::new(
4664 &local.path,
4665 "draft",
4666 format!("PR #{} is a draft", info.number),
4667 ));
4668 } else if is_conflicting(info.merge_state.as_deref()) {
4669 skipped.push(Skip::new(
4670 &local.path,
4671 "conflicting",
4672 format!("PR #{} has merge conflicts", info.number),
4673 ));
4674 } else if info.checks != PrCheckState::Success {
4675 skipped.push(Skip::new(
4676 &local.path,
4677 "checks-failing",
4678 format!(
4679 "PR #{} checks are {}",
4680 info.number,
4681 check_label(info.checks)
4682 ),
4683 ));
4684 } else {
4685 eligible.push(Eligible {
4686 path: local.path,
4687 number: info.number,
4688 url: info.url.clone(),
4689 branch: local.target.branch.clone(),
4690 pr_id: info.pr_id.clone(),
4691 already_queued: info.already_queued,
4692 });
4693 }
4694 }
4695 Ok((eligible, skipped))
4696}
4697
4698fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4703 let mut queued = Vec::new();
4704 let mut failed = Vec::new();
4705 for e in eligible {
4706 let path = e.path.to_string_lossy().to_string();
4707 if e.already_queued {
4708 queued.push(QueuedPr {
4709 path,
4710 number: e.number,
4711 already_queued: true,
4712 });
4713 continue;
4714 }
4715 match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4716 Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4717 path,
4718 number: e.number,
4719 already_queued: false,
4720 }),
4721 Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4722 path,
4723 number: e.number,
4724 error: msg,
4725 }),
4726 Err(err) => failed.push(EnqueueFailure {
4727 path,
4728 number: e.number,
4729 error: format!("{err:#}"),
4730 }),
4731 }
4732 }
4733 (queued, failed)
4734}
4735
4736fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4740 let target = canonical(path);
4741 entries
4742 .iter()
4743 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4744 .map(|e| (e.key.clone(), e.folders.len()))
4745 .collect()
4746}
4747
4748const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4755
4756const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4759
4760async fn await_windows_closed(
4768 registry: &WorktreesRegistry,
4769 path: &Path,
4770 requester: Option<&str>,
4771 timeout: Duration,
4772 poll: Duration,
4773) -> Result<()> {
4774 let deadline = std::time::Instant::now() + timeout;
4775 loop {
4776 let entries = registry.list();
4780 let path = path.to_path_buf();
4781 let requester = requester.map(str::to_string);
4782 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4783 windows_with_path(&entries, &path)
4784 .into_iter()
4785 .map(|(k, _)| k)
4786 .filter(|k| requester.as_deref() != Some(k))
4787 .collect()
4788 })
4789 .await
4790 .unwrap_or_default();
4791
4792 if remaining.is_empty() {
4793 return Ok(());
4794 }
4795 if std::time::Instant::now() >= deadline {
4796 bail!("window(s) did not close in time: {}", remaining.join(", "));
4797 }
4798 tokio::time::sleep(poll).await;
4799 }
4800}
4801
4802fn git_safety(path: &Path) -> Result<GitSafety> {
4809 if !path.exists() {
4810 return Ok(GitSafety {
4811 is_main: false,
4812 removable: true,
4813 risks: vec![],
4814 info: vec![Note::new("already-removed", "worktree no longer exists")],
4815 });
4816 }
4817 let repo = Repository::open(path)
4818 .with_context(|| format!("not a git worktree: {}", path.display()))?;
4819 if !repo.is_worktree() {
4821 return Ok(GitSafety {
4822 is_main: true,
4823 removable: false,
4824 risks: vec![],
4825 info: vec![Note::new(
4826 "main-working-tree",
4827 "the repository's main working tree is never deleted",
4828 )],
4829 });
4830 }
4831
4832 let mut risks = Vec::new();
4833 let mut info = Vec::new();
4834
4835 let (dirty, untracked) = count_dirty_untracked(&repo);
4836 if dirty > 0 {
4837 risks.push(Note::new(
4838 "dirty",
4839 format!("{dirty} modified tracked file(s) would be lost"),
4840 ));
4841 }
4842 if untracked > 0 {
4843 risks.push(Note::new(
4844 "untracked",
4845 format!("{untracked} untracked file(s) would be lost"),
4846 ));
4847 }
4848
4849 let state = repo.state();
4851 if state != RepositoryState::Clean {
4852 risks.push(Note::new(
4853 "in-progress",
4854 format!("an in-progress {state:?} operation would be lost"),
4855 ));
4856 }
4857
4858 if repo.head_detached().unwrap_or(false) {
4862 let lost = unreachable_commit_count(&repo).unwrap_or(0);
4863 if lost > 0 {
4864 risks.push(Note::new(
4865 "unreachable-commits",
4866 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4867 ));
4868 }
4869 }
4870
4871 if let Some(ahead) = current_branch_ahead(&repo) {
4874 if ahead > 0 {
4875 info.push(Note::new(
4876 "unpushed",
4877 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4878 ));
4879 }
4880 }
4881
4882 Ok(GitSafety {
4883 is_main: false,
4884 removable: true,
4885 risks,
4886 info,
4887 })
4888}
4889
4890fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4897 let mut opts = StatusOptions::new();
4898 opts.include_untracked(true)
4899 .recurse_untracked_dirs(true)
4900 .include_ignored(false)
4901 .exclude_submodules(true);
4902 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4903 return (0, 0);
4904 };
4905 let tracked = Status::INDEX_NEW
4908 | Status::INDEX_MODIFIED
4909 | Status::INDEX_DELETED
4910 | Status::INDEX_RENAMED
4911 | Status::INDEX_TYPECHANGE
4912 | Status::WT_MODIFIED
4913 | Status::WT_DELETED
4914 | Status::WT_TYPECHANGE
4915 | Status::WT_RENAMED
4916 | Status::CONFLICTED;
4917 let mut dirty = 0;
4918 let mut untracked = 0;
4919 for entry in statuses.iter() {
4920 let s = entry.status();
4921 if s.contains(Status::WT_NEW) {
4922 untracked += 1;
4923 }
4924 if s.intersects(tracked) {
4925 dirty += 1;
4926 }
4927 }
4928 (dirty, untracked)
4929}
4930
4931fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4938 let head_oid = repo.head().ok()?.target()?;
4939 let mut walk = repo.revwalk().ok()?;
4940 walk.push(head_oid).ok()?;
4941 for reference in repo.references().ok()? {
4942 let Ok(reference) = reference else { continue };
4943 if matches!(reference.name(), Ok("HEAD")) {
4946 continue;
4947 }
4948 if let Some(oid) = reference.target() {
4949 let _ = walk.hide(oid);
4950 }
4951 }
4952 Some(walk.flatten().count())
4953}
4954
4955fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4959 let head = repo.head().ok()?;
4960 if !head.is_branch() {
4961 return None;
4962 }
4963 let branch = git2::Branch::wrap(head);
4964 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4965}
4966
4967fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4973 let names = main_repo.worktrees()?;
4974 names
4975 .iter()
4976 .flatten() .flatten() .find(|name| {
4979 main_repo
4980 .find_worktree(name)
4981 .is_ok_and(|wt| canonical(wt.path()) == target)
4982 })
4983 .map(str::to_string)
4984 .ok_or_else(|| {
4985 anyhow!(
4986 "worktree {} is not registered in {}",
4987 target.display(),
4988 main_repo.path().display()
4989 )
4990 })
4991}
4992
4993const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
5002 Duration::from_millis(250),
5003 Duration::from_millis(500),
5004 Duration::from_secs(1),
5005 Duration::from_secs(1),
5006];
5007
5008fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
5014 matches!(
5015 e.raw_os_error(),
5016 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
5017 )
5018}
5019
5020fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
5026 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
5027}
5028
5029fn remove_dir_all_retrying_with(
5035 dir: &Path,
5036 backoff: &[Duration],
5037 mut remove: impl FnMut() -> std::io::Result<()>,
5038) -> Result<()> {
5039 let mut backoff = backoff.iter();
5040 loop {
5041 match remove() {
5042 Ok(()) => return Ok(()),
5043 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
5044 Err(e) => {
5045 if is_transient_rmdir_error(&e) {
5046 if let Some(delay) = backoff.next() {
5047 std::thread::sleep(*delay);
5048 continue;
5049 }
5050 }
5051 return Err(e).with_context(|| {
5052 format!("failed to remove worktree directory {}", dir.display())
5053 });
5054 }
5055 }
5056 }
5057}
5058
5059fn is_orphaned_worktree(path: &Path) -> bool {
5068 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
5071 return false;
5072 };
5073 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
5074 return false;
5075 };
5076 let admin = Path::new(admin);
5077 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
5079}
5080
5081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5086enum Removal {
5087 Pruned,
5089 AlreadyGone,
5091}
5092
5093fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
5118 if !path.exists() {
5119 return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
5120 }
5121 let repo = match Repository::open(path) {
5122 Ok(repo) => repo,
5123 Err(_) if is_orphaned_worktree(path) => {
5126 remove_dir_all_retrying(path)?;
5127 return Ok(Removal::Pruned);
5128 }
5129 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
5130 };
5131 if !repo.is_worktree() {
5132 bail!(
5133 "refusing to delete the main working tree: {}",
5134 path.display()
5135 );
5136 }
5137 let commondir = canonical(repo.commondir());
5140 let main_root = commondir
5141 .parent()
5142 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5143 .to_path_buf();
5144 drop(repo);
5146 let main_repo = Repository::open(&main_root)
5147 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5148 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5149 let worktree = main_repo.find_worktree(&name)?;
5150
5151 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5153 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5154 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5155 }
5156
5157 remove_dir_all_retrying(path)?;
5160
5161 let mut opts = git2::WorktreePruneOptions::new();
5166 opts.valid(true).working_tree(false);
5167 worktree
5168 .prune(Some(&mut opts))
5169 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5170 Ok(Removal::Pruned)
5171}
5172
5173fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5189 let mut roots: Vec<PathBuf> = Vec::new();
5190 let mut push = |root: PathBuf| {
5191 if !roots.contains(&root) {
5192 roots.push(root);
5193 }
5194 };
5195 for ancestor in path.ancestors().skip(1) {
5197 if let Ok(repo) = Repository::open(ancestor) {
5198 if !repo.is_worktree() {
5199 if let Some(root) = canonical(repo.commondir()).parent() {
5200 push(root.to_path_buf());
5201 }
5202 }
5203 }
5204 }
5205 for folder in windows.iter().flat_map(|w| &w.folders) {
5206 if let Ok(repo) = Repository::discover(folder) {
5207 if let Some(root) = canonical(repo.commondir()).parent() {
5208 push(root.to_path_buf());
5209 }
5210 }
5211 }
5212 roots
5213}
5214
5215fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5224 let target = canonical(path);
5225 for root in candidate_main_repos {
5226 let Ok(main_repo) = Repository::open(root) else {
5227 continue;
5228 };
5229 if main_repo.is_worktree() {
5231 continue;
5232 }
5233 let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5235 continue;
5236 };
5237 let worktree = main_repo.find_worktree(&name)?;
5238 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5239 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5240 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5241 }
5242 let mut opts = git2::WorktreePruneOptions::new();
5243 opts.valid(true).working_tree(false);
5244 worktree.prune(Some(&mut opts)).with_context(|| {
5245 format!(
5246 "failed to prune orphaned worktree metadata for {}",
5247 path.display()
5248 )
5249 })?;
5250 return Ok(Removal::Pruned);
5251 }
5252 Ok(Removal::AlreadyGone)
5253}
5254
5255#[cfg(test)]
5256#[allow(clippy::unwrap_used, clippy::expect_used)]
5257mod tests {
5258 use super::*;
5259 use crate::test_support::shim::{
5260 retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5261 };
5262 use std::sync::MutexGuard;
5263
5264 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5265 json!({
5266 "key": key,
5267 "folders": [folder],
5268 "repo": repo,
5269 "title": format!("{key}-title"),
5270 "pid": 1234,
5271 })
5272 }
5273
5274 fn windows_of(payload: &Value) -> &Vec<Value> {
5276 payload
5277 .get("windows")
5278 .and_then(Value::as_array)
5279 .expect("windows array")
5280 }
5281
5282 #[tokio::test]
5283 async fn name_and_unknown_op() {
5284 let svc = WorktreesService::new();
5285 assert_eq!(svc.name(), "worktrees");
5286 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5287 }
5288
5289 #[tokio::test]
5290 async fn handle_routes_ops_and_shapes_payloads() {
5291 let svc = WorktreesService::new();
5292 let payload = svc.handle("list", Value::Null).await.unwrap();
5294 assert_eq!(payload, json!({ "windows": [] }));
5295
5296 let reply = svc
5298 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5299 .await
5300 .unwrap();
5301 assert_eq!(reply, json!({ "ok": true }));
5302 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5303 assert_eq!(windows.len(), 1);
5304 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5305 assert!(windows[0].get("last_seen").is_some());
5306
5307 let known = svc
5309 .handle("heartbeat", json!({ "key": "w1" }))
5310 .await
5311 .unwrap();
5312 assert_eq!(known, json!({ "known": true }));
5313 let unknown = svc
5314 .handle("heartbeat", json!({ "key": "nope" }))
5315 .await
5316 .unwrap();
5317 assert_eq!(unknown, json!({ "known": false }));
5318
5319 let reloaded = svc
5321 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5322 .await
5323 .unwrap();
5324 assert_eq!(
5325 reloaded,
5326 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5327 );
5328 assert!(svc.registry.take_reload_pending("w1"));
5329
5330 let gone = svc
5332 .handle("unregister", json!({ "key": "w1" }))
5333 .await
5334 .unwrap();
5335 assert_eq!(gone, json!({ "removed": true }));
5336 let again = svc
5337 .handle("unregister", json!({ "key": "w1" }))
5338 .await
5339 .unwrap();
5340 assert_eq!(again, json!({ "removed": false }));
5341 }
5342
5343 #[derive(Clone)]
5355 struct StubBackend {
5356 trusted: bool,
5357 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5360 writes: Arc<Mutex<Vec<geometry::Frame>>>,
5362 }
5363
5364 impl StubBackend {
5365 fn new(trusted: bool) -> Self {
5368 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5369 title: title.to_string(),
5370 frame: geometry::Frame {
5371 x,
5372 y: 0.0,
5373 width,
5374 height: 600.0,
5375 },
5376 minimized: false,
5377 fullscreen: false,
5378 standard: true,
5379 focused: false,
5380 };
5381 Self {
5382 trusted,
5383 windows: Arc::new(Mutex::new(vec![
5384 window("plan.md — ref-tree", 0.0, 800.0),
5385 window("main.rs — other-tree", 900.0, 500.0),
5386 ])),
5387 writes: Arc::new(Mutex::new(Vec::new())),
5388 }
5389 }
5390
5391 fn writes(&self) -> Vec<geometry::Frame> {
5392 self.writes
5393 .lock()
5394 .unwrap_or_else(PoisonError::into_inner)
5395 .clone()
5396 }
5397
5398 fn frame_of(&self, index: usize) -> geometry::Frame {
5400 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5401 }
5402
5403 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5405 let clone = self.clone();
5406 move || clone
5407 }
5408 }
5409
5410 impl geometry::WindowBackend for StubBackend {
5411 fn trusted(&self) -> bool {
5412 self.trusted
5413 }
5414
5415 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5416 pids.iter()
5417 .filter(|p| **p == 11 || **p == 12)
5418 .map(|p| (*p, 900))
5419 .collect()
5420 }
5421
5422 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5423 if app_pid != 900 {
5424 return Ok(Vec::new());
5425 }
5426 Ok(self
5427 .windows
5428 .lock()
5429 .unwrap_or_else(PoisonError::into_inner)
5430 .clone())
5431 }
5432
5433 fn set_frame(
5434 &self,
5435 id: geometry::WindowId,
5436 frame: geometry::Frame,
5437 ) -> Result<geometry::Frame, String> {
5438 self.writes
5439 .lock()
5440 .unwrap_or_else(PoisonError::into_inner)
5441 .push(frame);
5442 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5443 let window = windows
5444 .get_mut(id.index)
5445 .ok_or_else(|| format!("no window at index {}", id.index))?;
5446 window.frame = frame;
5447 Ok(frame)
5448 }
5449 }
5450
5451 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5454 svc.registry.register(
5455 serde_json::from_value(json!({
5456 "key": key,
5457 "folders": [format!("/tmp/{key}")],
5458 "title": title,
5459 "pid": pid,
5460 }))
5461 .expect("valid register payload"),
5462 );
5463 }
5464
5465 #[tokio::test]
5466 async fn reposition_requires_a_resolvable_reference() {
5467 let svc = WorktreesService::new();
5468 assert!(svc.handle("reposition", json!({})).await.is_err());
5471 assert!(svc
5472 .handle("reposition", json!({ "reference_key": " " }))
5473 .await
5474 .is_err());
5475 assert!(svc
5476 .handle("reposition", json!({ "reference_key": "ghost" }))
5477 .await
5478 .is_err());
5479 }
5480
5481 #[tokio::test]
5482 async fn reposition_moves_targets_and_records_an_undo() {
5483 let svc = WorktreesService::new();
5484 register_window(&svc, "ref", "ref-tree", 11);
5485 register_window(&svc, "other", "other-tree", 12);
5486 let backend = StubBackend::new(true);
5487
5488 let reply = svc
5489 .reposition_with(
5490 serde_json::from_value(json!({
5491 "reference_key": "ref",
5492 "target_keys": ["other"],
5493 }))
5494 .unwrap(),
5495 backend.factory(),
5496 )
5497 .await
5498 .unwrap();
5499
5500 assert_eq!(reply["trusted"], json!(true));
5501 assert_eq!(reply["moved"], json!(1));
5502 assert_eq!(reply["skipped"], json!(0));
5503 assert_eq!(reply["undoable"], json!(true));
5504 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5505 assert_eq!(reply["results"][0]["key"], json!("other"));
5506 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5507 assert_eq!(backend.writes().len(), 1);
5509 assert_eq!(
5510 backend.writes()[0],
5511 backend.frame_of(0),
5512 "wrote the reference window's own frame"
5513 );
5514 assert_eq!(
5515 backend.frame_of(1),
5516 backend.frame_of(0),
5517 "the target now occupies the reference's frame"
5518 );
5519
5520 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5524 assert_eq!(undone["moved"], json!(1));
5525 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5526 assert!(undone.get("reference").is_none(), "undo has no reference");
5527 assert_eq!(
5528 backend.frame_of(1),
5529 geometry::Frame {
5530 x: 900.0,
5531 y: 0.0,
5532 width: 500.0,
5533 height: 600.0,
5534 },
5535 "restored to exactly the pre-move frame"
5536 );
5537
5538 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5539 assert_eq!(
5540 again,
5541 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5542 );
5543 }
5544
5545 #[tokio::test]
5546 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5547 let svc = WorktreesService::new();
5548 register_window(&svc, "ref", "ref-tree", 11);
5549 register_window(&svc, "other", "other-tree", 12);
5550 let backend = StubBackend::new(true);
5551
5552 let reply = svc
5553 .reposition_with(
5554 serde_json::from_value(json!({
5555 "reference_key": "ref",
5556 "target_keys": ["other"],
5557 "check": true,
5558 }))
5559 .unwrap(),
5560 backend.factory(),
5561 )
5562 .await
5563 .unwrap();
5564
5565 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5566 assert!(
5567 reply.get("undoable").is_none(),
5568 "a dry run leaves nothing to undo"
5569 );
5570 assert!(
5571 backend.writes().is_empty(),
5572 "a dry run must not touch a window"
5573 );
5574 }
5575
5576 #[tokio::test]
5577 async fn reposition_reports_a_missing_permission_as_data() {
5578 let svc = WorktreesService::new();
5579 register_window(&svc, "ref", "ref-tree", 11);
5580 register_window(&svc, "other", "other-tree", 12);
5581 let backend = StubBackend::new(false);
5582
5583 let reply = svc
5584 .reposition_with(
5585 serde_json::from_value(json!({
5586 "reference_key": "ref",
5587 "target_keys": ["other"],
5588 }))
5589 .unwrap(),
5590 backend.factory(),
5591 )
5592 .await
5593 .unwrap();
5594
5595 assert_eq!(reply["trusted"], json!(false));
5598 assert_eq!(reply["moved"], json!(0));
5599 assert!(backend.writes().is_empty());
5600 }
5601
5602 #[tokio::test]
5603 async fn a_stale_target_key_is_skipped_not_fatal() {
5604 let svc = WorktreesService::new();
5605 register_window(&svc, "ref", "ref-tree", 11);
5606 register_window(&svc, "other", "other-tree", 12);
5607 let backend = StubBackend::new(true);
5608
5609 let reply = svc
5610 .reposition_with(
5611 serde_json::from_value(json!({
5612 "reference_key": "ref",
5613 "target_keys": ["closed-since", "ref", "other"],
5616 }))
5617 .unwrap(),
5618 backend.factory(),
5619 )
5620 .await
5621 .unwrap();
5622
5623 let outcomes: Vec<&str> = reply["results"]
5624 .as_array()
5625 .unwrap()
5626 .iter()
5627 .map(|r| r["outcome"].as_str().unwrap())
5628 .collect();
5629 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5630 assert_eq!(reply["moved"], json!(1));
5631 assert_eq!(reply["skipped"], json!(2));
5632 }
5633
5634 #[tokio::test]
5635 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5636 let svc = WorktreesService::new();
5637 register_window(&svc, "ref", "twin", 11);
5640 register_window(&svc, "other", "other-tree", 12);
5641 let backend = StubBackend::new(true);
5644 {
5645 let mut windows = backend
5646 .windows
5647 .lock()
5648 .unwrap_or_else(PoisonError::into_inner);
5649 windows[0].title = "a.rs — twin".to_string();
5650 windows[1].title = "b.rs — twin".to_string();
5651 }
5652
5653 let reply = svc
5654 .reposition_with(
5655 serde_json::from_value(json!({
5656 "reference_key": "ref",
5657 "target_keys": ["other"],
5658 }))
5659 .unwrap(),
5660 backend.factory(),
5661 )
5662 .await
5663 .unwrap();
5664
5665 assert_eq!(reply["trusted"], json!(true));
5666 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5667 assert!(
5668 reply["blocked"]["detail"]
5669 .as_str()
5670 .is_some_and(|d| d.contains("twin")),
5671 "the reason should name the ambiguous title: {reply}"
5672 );
5673 assert_eq!(reply["results"], json!([]), "no target is attempted");
5674 assert!(reply.get("undoable").is_none());
5675 assert!(backend.writes().is_empty());
5676
5677 let undone = svc
5679 .reposition_undo_with(StubBackend::new(true).factory())
5680 .await
5681 .unwrap();
5682 assert_eq!(undone["moved"], json!(0));
5683 }
5684
5685 #[tokio::test]
5686 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5687 let svc = WorktreesService::new();
5688 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5689 assert_eq!(reply["moved"], json!(0));
5690 assert_eq!(reply["results"], json!([]));
5691 }
5692
5693 #[test]
5694 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5695 let empty = geometry::RepositionReport {
5696 trusted: true,
5697 blocked: None,
5698 reference: None,
5699 results: Vec::new(),
5700 undo: Vec::new(),
5701 };
5702 assert_eq!(outcome_kinds(&empty), "-");
5703 }
5704
5705 #[tokio::test]
5706 async fn handle_rejects_missing_or_empty_key() {
5707 let svc = WorktreesService::new();
5708 assert!(svc.handle("register", json!({})).await.is_err());
5710 assert!(svc
5711 .handle("register", json!({ "key": " " }))
5712 .await
5713 .is_err());
5714 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5716 assert!(svc.handle("unregister", json!({})).await.is_err());
5717 }
5718
5719 #[test]
5720 fn display_name_prefers_repo_then_folder_basename() {
5721 let base = WindowEntry {
5722 key: "k".to_string(),
5723 folders: vec![PathBuf::from("/home/me/project")],
5724 repo: Some("my-repo".to_string()),
5725 title: None,
5726 pid: None,
5727 last_seen: Utc::now(),
5728 last_active: Duration::ZERO,
5729 };
5730 assert_eq!(display_name(&base), "my-repo");
5731
5732 let no_repo = WindowEntry {
5733 repo: None,
5734 ..base.clone()
5735 };
5736 assert_eq!(display_name(&no_repo), "project");
5737
5738 let nothing = WindowEntry {
5739 repo: None,
5740 folders: vec![],
5741 ..base.clone()
5742 };
5743 assert_eq!(display_name(¬hing), "(no folder)");
5744
5745 let rootish = WindowEntry {
5748 repo: None,
5749 folders: vec![PathBuf::from("/")],
5750 ..base
5751 };
5752 assert_eq!(display_name(&rootish), "/");
5753 }
5754
5755 #[test]
5756 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5757 let now = Utc::now();
5758 let entries = vec![
5759 WindowEntry {
5764 key: "k2".to_string(),
5765 folders: vec![],
5766 repo: Some("solo".to_string()),
5767 title: Some("solo".to_string()),
5768 pid: None,
5769 last_seen: now,
5770 last_active: Duration::ZERO,
5771 },
5772 WindowEntry {
5775 key: "k1".to_string(),
5776 folders: vec![PathBuf::from("/tmp/a")],
5777 repo: Some("repo".to_string()),
5778 title: Some("a branch".to_string()),
5779 pid: None,
5780 last_seen: now,
5781 last_active: Duration::ZERO,
5782 },
5783 ];
5784 let items = window_menu_items(&entries);
5785 assert_eq!(items.len(), 2);
5787 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5788
5789 let action = items
5792 .iter()
5793 .find_map(|i| match i {
5794 MenuItem::Action(a) => Some(a),
5795 _ => None,
5796 })
5797 .expect("a focus action");
5798 assert_eq!(action.id, "focus:k1");
5799 assert_eq!(action.label, "repo · a branch");
5800
5801 let labels: Vec<&str> = items
5803 .iter()
5804 .filter_map(|i| match i {
5805 MenuItem::Label(t) => Some(t.as_str()),
5806 _ => None,
5807 })
5808 .collect();
5809 assert_eq!(labels, vec!["solo"]);
5810 }
5811
5812 #[tokio::test]
5813 async fn menu_and_status_shapes() {
5814 let svc = WorktreesService::new();
5815 let menu = svc.menu();
5817 assert_eq!(menu.title, "Worktrees");
5818 assert!(matches!(
5819 menu.items.first(),
5820 Some(MenuItem::Label(text)) if text == "No open windows"
5821 ));
5822 let status = svc.status().await;
5823 assert_eq!(status.name, "worktrees");
5824 assert!(status.healthy);
5825 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5826
5827 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5830 .await
5831 .unwrap();
5832 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5833 .await
5834 .unwrap();
5835 svc.handle(
5836 "register",
5837 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5838 )
5839 .await
5840 .unwrap();
5841 let status = svc.status().await;
5842 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5843
5844 let menu = svc.menu();
5845 assert_eq!(menu.items.len(), 3);
5847 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5848 let action_ids: Vec<&str> = menu
5849 .items
5850 .iter()
5851 .filter_map(|i| match i {
5852 MenuItem::Action(a) => Some(a.id.as_str()),
5853 _ => None,
5854 })
5855 .collect();
5856 assert!(action_ids.contains(&"focus:w1"));
5859 assert!(action_ids.contains(&"focus:w2"));
5860 assert!(!action_ids.contains(&"focus:w3"));
5861 }
5862
5863 #[test]
5864 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5865 let svc = WorktreesService::new();
5868 svc.start_menu_refresh();
5869 assert!(svc.refresh.lock().unwrap().is_none());
5870 }
5871
5872 #[tokio::test]
5873 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5874 let svc = WorktreesService::new();
5875 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5876 .await
5877 .unwrap();
5878 assert!(svc.menu_cache.lock().unwrap().is_none());
5880
5881 svc.start_menu_refresh();
5882 svc.start_menu_refresh();
5884
5885 let mut filled = false;
5887 for _ in 0..100 {
5888 if svc.menu_cache.lock().unwrap().is_some() {
5889 filled = true;
5890 break;
5891 }
5892 tokio::time::sleep(Duration::from_millis(10)).await;
5893 }
5894 assert!(filled, "background refresh should populate the menu cache");
5895
5896 let menu = svc.menu();
5898 assert_eq!(menu.title, "Worktrees");
5899 assert!(menu
5900 .items
5901 .iter()
5902 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5903
5904 svc.shutdown().await;
5906 assert!(svc.refresh.lock().unwrap().is_none());
5907 }
5908
5909 #[tokio::test]
5910 async fn default_constructs_an_empty_service() {
5911 let svc = WorktreesService::default();
5912 let payload = svc.handle("list", Value::Null).await.unwrap();
5913 assert_eq!(payload, json!({ "windows": [] }));
5914 }
5915
5916 #[tokio::test]
5919 async fn subscribe_streams_only_for_the_subscribe_op() {
5920 let svc = WorktreesService::new();
5921 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5925 assert!(svc.subscribe("list", &Value::Null).is_none());
5926 assert!(svc.subscribe("register", &Value::Null).is_none());
5927 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5928 }
5929
5930 #[tokio::test]
5931 async fn subscribe_snapshot_matches_the_tree_op() {
5932 let dir = tempfile::tempdir().unwrap();
5933 let repo = init_repo(dir.path());
5934 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5935 repo.set_head("refs/heads/main").unwrap();
5936
5937 let svc = WorktreesService::new();
5938 let stream = svc
5939 .subscribe("subscribe", &Value::Null)
5940 .expect("subscribe stream");
5941 assert_eq!(
5944 stream.snapshot().await,
5945 json!({ "repos": [], "show_closed": true })
5946 );
5947
5948 svc.handle(
5951 "register",
5952 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5953 )
5954 .await
5955 .unwrap();
5956 let snap = stream.snapshot().await;
5957 let tree = svc.handle("tree", Value::Null).await.unwrap();
5958 assert_eq!(snap, tree);
5959 let repos = snap["repos"].as_array().expect("repos array");
5960 assert_eq!(repos.len(), 1);
5961 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5962 }
5963
5964 #[tokio::test]
5965 async fn subscribe_changed_wakes_on_register() {
5966 let svc = WorktreesService::new();
5967 let mut stream = svc
5968 .subscribe("subscribe", &Value::Null)
5969 .expect("subscribe stream");
5970 tokio::select! {
5972 () = stream.changed() => panic!("changed resolved with no registry change"),
5973 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5974 }
5975 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5977 .await
5978 .unwrap();
5979 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5980 .await
5981 .expect("changed should resolve after a register");
5982 }
5983
5984 #[tokio::test]
5987 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5988 let reg = Arc::new(WorktreesRegistry::new());
5989 let cache = TreeSnapshotCache::with_ttl(
5991 reg,
5992 Arc::new(PrStatusCache::new()),
5993 Duration::from_secs(60),
5994 );
5995 let first = cache.snapshot().await;
5997 assert_eq!(cache.compute_count(), 1);
5998 let second = cache.snapshot().await;
6001 assert_eq!(
6002 cache.compute_count(),
6003 1,
6004 "an unchanged read must not rebuild"
6005 );
6006 assert_eq!(first, second);
6007 }
6008
6009 #[tokio::test]
6010 async fn tree_cache_single_flights_a_read_burst() {
6011 let reg = Arc::new(WorktreesRegistry::new());
6012 let cache = Arc::new(TreeSnapshotCache::with_ttl(
6013 reg,
6014 Arc::new(PrStatusCache::new()),
6015 Duration::from_secs(60),
6016 ));
6017 let mut handles = Vec::new();
6021 for _ in 0..16 {
6022 let cache = cache.clone();
6023 handles.push(tokio::spawn(async move { cache.snapshot().await }));
6024 }
6025 let mut results = Vec::new();
6026 for handle in handles {
6027 results.push(handle.await.unwrap());
6028 }
6029 assert_eq!(
6030 cache.compute_count(),
6031 1,
6032 "a concurrent read burst must build the tree once"
6033 );
6034 assert!(
6035 results.windows(2).all(|w| w[0] == w[1]),
6036 "every reader must observe the identical snapshot"
6037 );
6038 }
6039
6040 #[tokio::test]
6041 async fn tree_cache_rebuilds_on_registry_change() {
6042 let reg = Arc::new(WorktreesRegistry::new());
6043 let cache = TreeSnapshotCache::with_ttl(
6044 reg.clone(),
6045 Arc::new(PrStatusCache::new()),
6046 Duration::from_secs(60),
6047 );
6048 cache.snapshot().await;
6049 assert_eq!(cache.compute_count(), 1);
6050 assert!(reg.set_show_closed(false));
6054 cache.snapshot().await;
6055 assert_eq!(
6056 cache.compute_count(),
6057 2,
6058 "a generation bump must force a rebuild"
6059 );
6060 }
6061
6062 #[tokio::test]
6063 async fn tree_cache_rebuilds_after_ttl_expiry() {
6064 let reg = Arc::new(WorktreesRegistry::new());
6065 let cache =
6068 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
6069 cache.snapshot().await;
6070 cache.snapshot().await;
6071 assert_eq!(
6072 cache.compute_count(),
6073 2,
6074 "an expired TTL must force a rebuild each read"
6075 );
6076 }
6077
6078 #[tokio::test]
6079 async fn subscribe_streams_share_one_build_per_generation() {
6080 let svc = WorktreesService::new();
6081 let s1 = svc
6082 .subscribe("subscribe", &Value::Null)
6083 .expect("subscribe stream");
6084 let s2 = svc
6085 .subscribe("subscribe", &Value::Null)
6086 .expect("subscribe stream");
6087 let a = s1.snapshot().await;
6090 let b = s2.snapshot().await;
6091 assert_eq!(a, b);
6092 assert_eq!(
6093 svc.tree_cache.compute_count(),
6094 1,
6095 "N streams on one generation must share a single build"
6096 );
6097 }
6098
6099 #[tokio::test]
6102 async fn set_show_closed_toggles_the_snapshot_field() {
6103 let svc = WorktreesService::new();
6104 assert_eq!(
6106 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6107 json!(true)
6108 );
6109 let reply = svc
6111 .handle("set-show-closed", json!({ "show_closed": false }))
6112 .await
6113 .unwrap();
6114 assert_eq!(reply, json!({ "ok": true }));
6115 assert_eq!(
6116 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6117 json!(false)
6118 );
6119 }
6120
6121 #[tokio::test]
6122 async fn set_show_closed_rejects_a_non_boolean_payload() {
6123 let svc = WorktreesService::new();
6124 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
6125 assert!(svc
6126 .handle("set-show-closed", json!({ "show_closed": "yes" }))
6127 .await
6128 .is_err());
6129 }
6130
6131 #[tokio::test]
6132 async fn set_show_closed_wakes_the_subscription() {
6133 let svc = WorktreesService::new();
6134 let mut stream = svc
6135 .subscribe("subscribe", &Value::Null)
6136 .expect("subscribe stream");
6137 svc.handle("set-show-closed", json!({ "show_closed": false }))
6139 .await
6140 .unwrap();
6141 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6142 .await
6143 .expect("changed should resolve after a toggle flip");
6144 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6146 }
6147
6148 #[tokio::test]
6149 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6150 let dir = tempfile::tempdir().unwrap();
6153 github_repo(dir.path());
6154 let svc = WorktreesService::new();
6155 svc.handle(
6156 "register",
6157 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6158 )
6159 .await
6160 .unwrap();
6161
6162 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6163 assert!(
6164 repo.get("polling_enabled").is_none(),
6165 "default off omits the flag: {repo:?}"
6166 );
6167
6168 let reply = svc
6169 .handle(
6170 "set-polling",
6171 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6172 )
6173 .await
6174 .unwrap();
6175 assert_eq!(reply, json!({ "ok": true }));
6176 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6177 assert_eq!(repo["polling_enabled"], json!(true));
6178
6179 svc.handle(
6180 "set-polling",
6181 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6182 )
6183 .await
6184 .unwrap();
6185 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6186 assert!(repo.get("polling_enabled").is_none());
6187 }
6188
6189 #[tokio::test]
6190 async fn set_polling_rejects_missing_or_empty_fields() {
6191 let svc = WorktreesService::new();
6192 assert!(svc
6194 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6195 .await
6196 .is_err());
6197 assert!(svc
6199 .handle("set-polling", json!({ "enabled": true }))
6200 .await
6201 .is_err());
6202 assert!(svc
6204 .handle(
6205 "set-polling",
6206 json!({ "owner": " ", "name": "n", "enabled": true })
6207 )
6208 .await
6209 .is_err());
6210 }
6211
6212 #[tokio::test]
6213 async fn set_polling_wakes_the_subscription() {
6214 let dir = tempfile::tempdir().unwrap();
6215 github_repo(dir.path());
6216 let svc = WorktreesService::new();
6217 svc.handle(
6218 "register",
6219 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6220 )
6221 .await
6222 .unwrap();
6223 let mut stream = svc
6224 .subscribe("subscribe", &Value::Null)
6225 .expect("subscribe stream");
6226 svc.handle(
6227 "set-polling",
6228 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6229 )
6230 .await
6231 .unwrap();
6232 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6233 .await
6234 .expect("changed should resolve after enabling a repo");
6235 let repo = repos_of(&stream.snapshot().await)[0].clone();
6236 assert_eq!(repo["polling_enabled"], json!(true));
6237 }
6238
6239 #[tokio::test]
6240 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6241 let dir = tempfile::tempdir().unwrap();
6245 let repo = github_repo(dir.path());
6246 let head = repo.head().unwrap().target().unwrap().to_string();
6247 let svc = WorktreesService::new();
6248 svc.handle(
6249 "register",
6250 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6251 )
6252 .await
6253 .unwrap();
6254 svc.registry.set_polling("rust-works", "omni-dev", true);
6255
6256 let mut badges = HashMap::new();
6257 badges.insert(
6258 PrTarget {
6259 owner: "rust-works".into(),
6260 name: "omni-dev".into(),
6261 branch: "main".into(),
6262 },
6263 pr(pending_badge(7, &head)),
6264 );
6265 svc.pr_cache.replace(badges);
6266
6267 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6268 assert_eq!(wt["pr"]["number"], json!(7));
6269
6270 svc.handle(
6271 "set-polling",
6272 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6273 )
6274 .await
6275 .unwrap();
6276 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6277 assert!(
6278 wt.get("pr").is_none(),
6279 "a disabled repo must carry no badge: {wt:?}"
6280 );
6281 }
6282
6283 #[tokio::test]
6284 async fn an_expired_lease_drops_the_flag_and_badges() {
6285 let dir = tempfile::tempdir().unwrap();
6289 let repo = github_repo(dir.path());
6290 let head = repo.head().unwrap().target().unwrap().to_string();
6291 let svc = WorktreesService::new();
6292 svc.handle(
6293 "register",
6294 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6295 )
6296 .await
6297 .unwrap();
6298 svc.registry.set_polling("rust-works", "omni-dev", true);
6299 let mut badges = HashMap::new();
6300 badges.insert(
6301 PrTarget {
6302 owner: "rust-works".into(),
6303 name: "omni-dev".into(),
6304 branch: "main".into(),
6305 },
6306 pr(pending_badge(7, &head)),
6307 );
6308 svc.pr_cache.replace(badges);
6309
6310 let snap = svc.handle("tree", Value::Null).await.unwrap();
6312 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6313 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6314 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6315
6316 svc.registry.set_polling_expiry(
6318 "rust-works",
6319 "omni-dev",
6320 Utc::now() - chrono::Duration::minutes(1),
6321 );
6322
6323 let snap = svc.handle("tree", Value::Null).await.unwrap();
6324 let repo0 = &repos_of(&snap)[0];
6325 assert!(
6326 repo0.get("polling_enabled").is_none(),
6327 "expired lease drops the flag: {repo0:?}"
6328 );
6329 assert!(
6330 repo0["worktrees"][0].get("pr").is_none(),
6331 "expired lease drops the badge"
6332 );
6333 assert!(
6334 pr_targets_from_snapshot(&snap).is_empty(),
6335 "the poller no longer watches an expired repo"
6336 );
6337 }
6338
6339 #[tokio::test]
6340 async fn polling_prefs_persist_across_reloads_with_0600() {
6341 let dir = tempfile::tempdir().unwrap();
6344 let prefs = dir.path().join("worktrees-polling.json");
6345
6346 let svc = WorktreesService::new();
6347 svc.load_polling_prefs(prefs.clone());
6348 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6349 svc.handle(
6350 "set-polling",
6351 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6352 )
6353 .await
6354 .unwrap();
6355 assert!(prefs.exists());
6356 #[cfg(unix)]
6357 {
6358 use std::os::unix::fs::PermissionsExt;
6359 assert_eq!(
6360 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6361 0o600
6362 );
6363 }
6364
6365 let svc2 = WorktreesService::new();
6367 svc2.load_polling_prefs(prefs.clone());
6368 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6369
6370 svc2.handle(
6372 "set-polling",
6373 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6374 )
6375 .await
6376 .unwrap();
6377 let svc3 = WorktreesService::new();
6378 svc3.load_polling_prefs(prefs);
6379 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6380 }
6381
6382 #[test]
6383 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6384 let dir = tempfile::tempdir().unwrap();
6389
6390 let corrupt = dir.path().join("worktrees-polling.json");
6392 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6393 let svc = WorktreesService::new();
6394 svc.load_polling_prefs(corrupt);
6395 assert!(svc.registry.enabled_polling_repos().is_empty());
6396
6397 let as_dir = dir.path().join("is-a-directory");
6399 std::fs::create_dir(&as_dir).unwrap();
6400 let svc2 = WorktreesService::new();
6401 svc2.load_polling_prefs(as_dir);
6402 assert!(svc2.registry.enabled_polling_repos().is_empty());
6403 }
6404
6405 #[tokio::test]
6406 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6407 let dir = tempfile::tempdir().unwrap();
6410 github_repo(dir.path());
6411 let bin_dir = tempfile::tempdir().unwrap();
6412 let marker = bin_dir.path().join("spawned");
6413 let fake = bin_dir.path().join("fake-gh");
6414 std::fs::write(
6415 &fake,
6416 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6417 )
6418 .unwrap();
6419 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6420 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6421 std::fs::set_permissions(&fake, perms).unwrap();
6422
6423 let svc = WorktreesService::new();
6424 svc.handle(
6425 "register",
6426 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6427 )
6428 .await
6429 .unwrap();
6430 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6432 tokio::time::sleep(Duration::from_millis(200)).await;
6433 svc.shutdown().await;
6434 assert!(
6435 !marker.exists(),
6436 "a registered-but-not-enabled repo must drive zero gh"
6437 );
6438 }
6439
6440 #[tokio::test]
6441 async fn menu_action_rejects_unknown_and_missing_window() {
6442 let svc = WorktreesService::new();
6443 assert!(svc.menu_action("bogus").await.is_err());
6444 assert!(svc.menu_action("focus:nope").await.is_err());
6446 svc.shutdown().await;
6447 }
6448
6449 struct VscodeBinGuard(Option<std::ffi::OsString>);
6456 impl Drop for VscodeBinGuard {
6457 fn drop(&mut self) {
6458 match self.0.take() {
6459 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6460 None => std::env::remove_var(VSCODE_BIN_ENV),
6461 }
6462 }
6463 }
6464
6465 #[tokio::test]
6466 async fn menu_action_focus_resolves_folder_and_spawns() {
6467 let dir = tempfile::tempdir().unwrap();
6468 let svc = WorktreesService::new();
6469 svc.handle(
6470 "register",
6471 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6472 )
6473 .await
6474 .unwrap();
6475
6476 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6479 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6480 svc.menu_action("focus:w1").await.unwrap();
6481 }
6482
6483 #[tokio::test]
6484 async fn open_rejects_missing_relative_or_nonexistent_path() {
6485 let svc = WorktreesService::new();
6486 assert!(svc.handle("open", json!({})).await.is_err());
6488 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6489 assert!(svc
6492 .handle("open", json!({ "path": "relative/dir" }))
6493 .await
6494 .is_err());
6495 assert!(svc
6496 .handle("open", json!({ "path": "-flag" }))
6497 .await
6498 .is_err());
6499 assert!(svc
6502 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6503 .await
6504 .is_err());
6505 svc.shutdown().await;
6506 }
6507
6508 #[tokio::test]
6509 async fn open_focuses_an_existing_absolute_dir() {
6510 let dir = tempfile::tempdir().unwrap();
6511 let svc = WorktreesService::new();
6512 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6517 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6518 let reply = svc
6519 .handle("open", json!({ "path": dir.path() }))
6520 .await
6521 .unwrap();
6522 assert_eq!(reply, json!({ "ok": true }));
6523 svc.shutdown().await;
6524 }
6525
6526 #[test]
6527 fn focus_window_with_validates_folder_then_spawns() {
6528 let dir = tempfile::tempdir().unwrap();
6529 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6531 assert!(
6532 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6533 );
6534 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6536 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6538 }
6539
6540 #[test]
6541 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6542 assert_eq!(
6544 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6545 PathBuf::from("/custom/code")
6546 );
6547 let existing = tempfile::NamedTempFile::new().unwrap();
6549 let existing_path = existing.path().to_str().unwrap();
6550 assert_eq!(
6551 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6552 PathBuf::from(existing_path)
6553 );
6554 assert_eq!(
6556 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6557 PathBuf::from("code")
6558 );
6559 let _ = resolve_code_binary();
6561 }
6562
6563 fn init_repo(dir: &Path) -> Repository {
6568 let repo = Repository::init(dir).unwrap();
6569 let mut cfg = repo.config().unwrap();
6570 cfg.set_str("user.name", "Test").unwrap();
6571 cfg.set_str("user.email", "test@example.com").unwrap();
6572 repo
6573 }
6574
6575 fn empty_commit(
6578 repo: &Repository,
6579 refname: Option<&str>,
6580 parents: &[&git2::Commit<'_>],
6581 msg: &str,
6582 ) -> git2::Oid {
6583 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6584 let tree = repo
6585 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6586 .unwrap();
6587 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6588 .unwrap()
6589 }
6590
6591 fn commit_file(
6596 repo: &Repository,
6597 refname: &str,
6598 name: &str,
6599 content: &[u8],
6600 msg: &str,
6601 ) -> git2::Oid {
6602 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6603 let blob = repo.blob(content).unwrap();
6604 let mut builder = repo.treebuilder(None).unwrap();
6605 builder.insert(name, blob, 0o100_644).unwrap();
6606 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6607 let parent = repo
6608 .refname_to_id(refname)
6609 .ok()
6610 .and_then(|oid| repo.find_commit(oid).ok());
6611 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6612 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6613 .unwrap()
6614 }
6615
6616 fn diverging_repo(dir: &Path) -> Repository {
6619 let repo = init_repo(dir);
6620 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6622 let a_commit = repo.find_commit(a).unwrap();
6623 let c = empty_commit(&repo, None, &[&a_commit], "C");
6625 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6626 .unwrap();
6627 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6629 drop(a_commit);
6631 repo.set_head("refs/heads/main").unwrap();
6632 let mut cfg = repo.config().unwrap();
6634 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6635 .unwrap();
6636 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6637 .unwrap();
6638 cfg.set_str("branch.main.remote", "origin").unwrap();
6639 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6640 repo
6641 }
6642
6643 fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6649 let repo = init_repo(dir);
6650 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6651 let a_commit = repo.find_commit(a).unwrap();
6652 let c = empty_commit(&repo, None, &[&a_commit], "C");
6653 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6654 .unwrap();
6655 drop(a_commit);
6656 repo.set_head("refs/heads/main").unwrap();
6657 repo
6658 }
6659
6660 #[test]
6661 fn git_status_reads_branch_and_ahead_behind() {
6662 let dir = tempfile::tempdir().unwrap();
6663 let _repo = diverging_repo(dir.path());
6664 let status = git_status(dir.path());
6665 assert_eq!(status.branch.as_deref(), Some("main"));
6666 assert_eq!(status.ahead, Some(1));
6667 assert_eq!(status.behind, Some(1));
6668 assert_eq!(
6670 status.main_repo.as_deref(),
6671 dir.path().file_name().and_then(|n| n.to_str())
6672 );
6673 assert!(!status.is_worktree);
6674 }
6675
6676 #[test]
6677 fn git_status_empty_repo_is_unborn() {
6678 let dir = tempfile::tempdir().unwrap();
6682 init_repo(dir.path());
6683 let status = git_status(dir.path());
6684 assert_eq!(status.branch, None);
6685 assert_eq!(status.head_sha, None);
6687 assert_eq!(status.ahead, None);
6688 assert_eq!(status.behind, None);
6689 assert_eq!(
6690 status.main_repo.as_deref(),
6691 dir.path().file_name().and_then(|n| n.to_str())
6692 );
6693 assert!(!status.is_worktree);
6694 }
6695
6696 #[test]
6697 fn git_status_no_upstream_reports_branch_only() {
6698 let dir = tempfile::tempdir().unwrap();
6699 let repo = init_repo(dir.path());
6700 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6701 repo.set_head("refs/heads/main").unwrap();
6702 let status = git_status(dir.path());
6703 assert_eq!(status.branch.as_deref(), Some("main"));
6704 assert_eq!(status.ahead, None);
6706 assert_eq!(status.behind, None);
6707 assert_eq!(status.upstream_sha, None);
6710 }
6711
6712 #[test]
6713 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6714 let plain = tempfile::tempdir().unwrap();
6716 assert_eq!(git_status(plain.path()), GitStatus::default());
6717
6718 let dir = tempfile::tempdir().unwrap();
6721 let repo = init_repo(dir.path());
6722 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6723 repo.set_head_detached(a).unwrap();
6724 let status = git_status(dir.path());
6725 assert_eq!(status.branch, None);
6726 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6729 assert_eq!(status.ahead, None);
6730 assert_eq!(status.behind, None);
6731 assert_eq!(status.upstream_sha, None);
6734 assert_eq!(
6735 status.main_repo.as_deref(),
6736 dir.path().file_name().and_then(|n| n.to_str())
6737 );
6738 assert!(!status.is_worktree);
6739 }
6740
6741 #[test]
6744 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6745 let dir = tempfile::tempdir().unwrap();
6749 let repo = diverging_repo(dir.path());
6750 let status = git_status_cheap(dir.path());
6751 assert_eq!(status.branch.as_deref(), Some("main"));
6752 assert_eq!(status.ahead, None);
6753 assert_eq!(status.behind, None);
6754 assert_eq!(
6755 status.main_repo.as_deref(),
6756 dir.path().file_name().and_then(|n| n.to_str())
6757 );
6758 let head = repo.head().unwrap().target().unwrap();
6761 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6762 }
6763
6764 #[test]
6767 fn git_status_head_sha_tracks_new_commits() {
6768 let dir = tempfile::tempdir().unwrap();
6774 let repo = init_repo(dir.path());
6775 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6776 repo.set_head("refs/heads/main").unwrap();
6777 let before = git_status_cheap(dir.path());
6778 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6779
6780 let head = repo.find_commit(a).unwrap();
6781 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6782 let after = git_status_cheap(dir.path());
6783 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6784 assert_ne!(before.head_sha, after.head_sha);
6785 assert_eq!(before.branch, after.branch);
6788 }
6789
6790 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6796 repo.reference("refs/remotes/origin/main", oid, true, "push")
6797 .unwrap();
6798 }
6799
6800 #[test]
6801 fn git_status_upstream_sha_tracks_a_push() {
6802 let dir = tempfile::tempdir().unwrap();
6809 let repo = diverging_repo(dir.path());
6810 let before = git_status(dir.path());
6811 assert_eq!(before.ahead, Some(1));
6812 assert_eq!(before.behind, Some(1));
6813
6814 let head = repo.head().unwrap().target().unwrap();
6815 simulate_push(&repo, head);
6816 let after = git_status(dir.path());
6817
6818 assert_eq!(
6820 after.upstream_sha.as_deref(),
6821 Some(head.to_string().as_str())
6822 );
6823 assert_ne!(before.upstream_sha, after.upstream_sha);
6824 assert_eq!(after.ahead, Some(0));
6825 assert_eq!(after.behind, Some(0));
6826 assert_eq!(before.branch, after.branch);
6830 assert_eq!(before.head_sha, after.head_sha);
6831 }
6832
6833 #[test]
6834 fn git_status_cheap_reports_upstream_sha() {
6835 let dir = tempfile::tempdir().unwrap();
6840 let repo = diverging_repo(dir.path());
6841 let status = git_status_cheap(dir.path());
6842 let upstream = repo
6843 .find_branch("origin/main", git2::BranchType::Remote)
6844 .unwrap()
6845 .get()
6846 .target()
6847 .unwrap();
6848 assert_eq!(
6849 status.upstream_sha.as_deref(),
6850 Some(upstream.to_string().as_str())
6851 );
6852 assert_eq!(status.ahead, None);
6853 assert_eq!(status.behind, None);
6854 }
6855
6856 #[test]
6857 fn folder_divergence_computes_ahead_behind_and_degrades() {
6858 let dir = tempfile::tempdir().unwrap();
6860 let _repo = diverging_repo(dir.path());
6861 assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6862
6863 let no_up = tempfile::tempdir().unwrap();
6865 let repo = init_repo(no_up.path());
6866 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6867 repo.set_head("refs/heads/main").unwrap();
6868 assert_eq!(folder_divergence(no_up.path()).ahead_behind, None);
6869
6870 let detached = tempfile::tempdir().unwrap();
6872 let drepo = init_repo(detached.path());
6873 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6874 drepo.set_head_detached(a).unwrap();
6875 assert_eq!(folder_divergence(detached.path()).ahead_behind, None);
6876 let plain = tempfile::tempdir().unwrap();
6877 assert_eq!(folder_divergence(plain.path()).ahead_behind, None);
6878 }
6879
6880 #[test]
6883 fn folder_divergence_computes_main_behind_and_degrades() {
6884 let dir = tempfile::tempdir().unwrap();
6888 let _repo = behind_main_no_upstream_repo(dir.path());
6889 assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
6890
6891 let detached = tempfile::tempdir().unwrap();
6893 let drepo = init_repo(detached.path());
6894 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6895 drepo.set_head_detached(a).unwrap();
6896 assert_eq!(folder_divergence(detached.path()).main_behind, None);
6897 let plain = tempfile::tempdir().unwrap();
6898 assert_eq!(folder_divergence(plain.path()).main_behind, None);
6899 }
6900
6901 #[test]
6902 fn folder_divergence_skips_main_behind_when_own_upstream_is_the_default_branch() {
6903 let dir = tempfile::tempdir().unwrap();
6908 let _repo = diverging_repo(dir.path());
6909 assert_eq!(folder_divergence(dir.path()).main_behind, None);
6910 }
6911
6912 #[test]
6913 fn folder_divergence_has_no_main_behind_without_a_resolvable_default_branch() {
6914 let dir = tempfile::tempdir().unwrap();
6916 let repo = init_repo(dir.path());
6917 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6918 repo.set_head("refs/heads/main").unwrap();
6919 assert_eq!(folder_divergence(dir.path()).main_behind, None);
6920 }
6921
6922 #[test]
6923 fn folder_divergence_reports_independent_main_behind_and_ahead_behind_counts() {
6924 let dir = tempfile::tempdir().unwrap();
6925 let repo = init_repo(dir.path());
6926 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6927 let base_commit = repo.find_commit(base).unwrap();
6928
6929 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6931 let m1_commit = repo.find_commit(m1).unwrap();
6932 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6933 let m2_commit = repo.find_commit(m2).unwrap();
6934 let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6935 repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6936 .unwrap();
6937
6938 let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6941 repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6942 .unwrap();
6943 empty_commit(
6944 &repo,
6945 Some("refs/heads/feature"),
6946 &[&base_commit],
6947 "local-feature",
6948 );
6949 drop(base_commit);
6950 drop(m1_commit);
6951 drop(m2_commit);
6952
6953 repo.set_head("refs/heads/feature").unwrap();
6954 let mut cfg = repo.config().unwrap();
6955 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6956 .unwrap();
6957 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6958 .unwrap();
6959 cfg.set_str("branch.feature.remote", "origin").unwrap();
6960 cfg.set_str("branch.feature.merge", "refs/heads/feature")
6961 .unwrap();
6962
6963 assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6964 assert_eq!(folder_divergence(dir.path()).main_behind, Some(3));
6965 }
6966
6967 struct SharedCorpus {
6976 _main_dir: tempfile::TempDir,
6977 _wts_dir: tempfile::TempDir,
6978 repo: Repository,
6979 main: PathBuf,
6980 wts: PathBuf,
6981 tip: git2::Oid,
6982 origin_main: git2::Oid,
6983 }
6984
6985 impl SharedCorpus {
6986 fn wt(&self, name: &str) -> PathBuf {
6987 self.wts.join(name)
6988 }
6989
6990 fn track(&self, branch: &str, upstream_ref: &str) {
6992 let mut cfg = self.repo.config().unwrap();
6993 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
6994 .unwrap();
6995 cfg.set_str(&format!("branch.{branch}.merge"), upstream_ref)
6996 .unwrap();
6997 }
6998
6999 fn cases(&self) -> Vec<(&'static str, PathBuf)> {
7001 vec![
7002 ("main checkout", self.main.clone()),
7003 ("tracked", self.wt("tracked")),
7004 (
7005 "tracked, from a subdirectory",
7006 self.wt("tracked").join("sub"),
7007 ),
7008 ("untracked", self.wt("untracked")),
7009 ("on the default branch", self.wt("on-default")),
7010 ("dangling upstream", self.wt("dangling")),
7011 ("detached", self.wt("detached")),
7012 ("unborn", self.wt("unborn")),
7013 ("deleted directory", self.wt("deleted")),
7014 ("never existed", self.wts.join("never-existed")),
7015 ]
7016 }
7017 }
7018
7019 fn shared_corpus() -> SharedCorpus {
7020 let main_dir = tempfile::tempdir().unwrap();
7021 let wts_dir = tempfile::tempdir().unwrap();
7022 let repo = diverging_repo(main_dir.path());
7023 let tip = repo.refname_to_id("refs/heads/main").unwrap();
7024 let origin_main = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7025 let base = repo.find_commit(tip).unwrap().parent_id(0).unwrap();
7026 let corpus = SharedCorpus {
7027 main: main_dir.path().to_path_buf(),
7028 wts: wts_dir.path().to_path_buf(),
7029 _main_dir: main_dir,
7030 _wts_dir: wts_dir,
7031 repo,
7032 tip,
7033 origin_main,
7034 };
7035
7036 add_worktree(&corpus.repo, tip, &corpus.wt("tracked"), "tracked");
7038 corpus
7039 .repo
7040 .reference("refs/remotes/origin/tracked", base, true, "origin tracked")
7041 .unwrap();
7042 corpus.track("tracked", "refs/heads/tracked");
7043 std::fs::create_dir(corpus.wt("tracked").join("sub")).unwrap();
7044
7045 add_worktree(&corpus.repo, tip, &corpus.wt("untracked"), "untracked");
7047
7048 add_worktree(&corpus.repo, base, &corpus.wt("on-default"), "on-default");
7050 corpus.track("on-default", "refs/heads/main");
7051
7052 add_worktree(&corpus.repo, tip, &corpus.wt("dangling"), "dangling");
7054 corpus.track("dangling", "refs/heads/gone");
7055
7056 add_worktree(&corpus.repo, tip, &corpus.wt("detached"), "detached");
7057 Repository::open(corpus.wt("detached"))
7058 .unwrap()
7059 .set_head_detached(tip)
7060 .unwrap();
7061
7062 add_worktree(&corpus.repo, tip, &corpus.wt("unborn"), "unborn");
7063 Repository::open(corpus.wt("unborn"))
7064 .unwrap()
7065 .set_head("refs/heads/not-yet")
7066 .unwrap();
7067
7068 add_worktree(&corpus.repo, tip, &corpus.wt("deleted"), "deleted");
7070 std::fs::remove_dir_all(corpus.wt("deleted")).unwrap();
7071 corpus
7072 }
7073
7074 #[test]
7079 fn shared_path_matches_the_discover_path_for_every_worktree_shape() {
7080 let corpus = shared_corpus();
7081 let pool = shared_repo::RepoPool::new(4);
7082
7083 for (case, path) in corpus.cases() {
7084 assert_eq!(
7085 shared_repo::divergence(&pool, &path),
7086 Some(folder_divergence(&path)),
7087 "{case}"
7088 );
7089 }
7090
7091 let expect = |name: &str, ahead_behind, main_behind| {
7092 assert_eq!(
7093 folder_divergence_shared(&pool, &corpus.wt(name)),
7094 Divergence {
7095 ahead_behind,
7096 main_behind,
7097 ..Divergence::default()
7098 },
7099 "{name}"
7100 );
7101 };
7102 expect("tracked", Some((1, 0)), Some(1));
7103 expect("untracked", None, Some(1));
7104 expect("on-default", Some((0, 1)), None);
7105 expect("dangling", None, Some(1));
7106 for silent in ["detached", "unborn", "deleted"] {
7107 expect(silent, None, None);
7108 }
7109 assert_eq!(
7111 folder_divergence_shared(&pool, &corpus.main),
7112 Divergence {
7113 ahead_behind: Some((1, 1)),
7114 main_behind: None,
7115 ..Divergence::default()
7116 }
7117 );
7118 assert_eq!(
7119 folder_divergence_shared(&pool, &corpus.wt("tracked").join("sub")),
7120 folder_divergence_shared(&pool, &corpus.wt("tracked"))
7121 );
7122 assert_eq!(pool.opens(), 1);
7124 }
7125
7126 #[test]
7139 fn a_shallow_repository_is_answered_by_the_discover_path() {
7140 let corpus = shared_corpus();
7141 let pool = shared_repo::RepoPool::new(4);
7142 assert_eq!(
7144 shared_repo::divergence(&pool, &corpus.wt("tracked")),
7145 Some(folder_divergence(&corpus.wt("tracked")))
7146 );
7147 assert!(!folder_divergence_shared(&pool, &corpus.main).shallow);
7148 assert_eq!(pool.opens(), 1);
7149
7150 std::fs::write(
7151 corpus.repo.path().join("shallow"),
7152 format!("{}\n", corpus.tip),
7153 )
7154 .unwrap();
7155
7156 for (case, path) in corpus.cases() {
7157 let in_repo = !matches!(case, "deleted directory" | "never existed");
7160 assert_eq!(
7161 shared_repo::divergence(&pool, &path).is_none(),
7162 in_repo,
7163 "{case}"
7164 );
7165 assert_eq!(
7166 folder_divergence_shared(&pool, &path),
7167 folder_divergence(&path),
7168 "{case}"
7169 );
7170 }
7171 let shallow = |ahead_behind, main_behind| Divergence {
7174 ahead_behind,
7175 main_behind,
7176 shallow: true,
7177 };
7178 assert_eq!(
7179 folder_divergence_shared(&pool, &corpus.main),
7180 shallow(Some((1, 2)), None)
7181 );
7182 for (name, expected) in [
7183 ("tracked", shallow(Some((1, 1)), Some(2))),
7184 ("untracked", shallow(None, Some(2))),
7185 ("on-default", shallow(Some((0, 1)), None)),
7186 ("dangling", shallow(None, Some(2))),
7187 ("detached", shallow(None, None)),
7188 ("unborn", shallow(None, None)),
7189 ] {
7190 assert_eq!(
7191 folder_divergence_shared(&pool, &corpus.wt(name)),
7192 expected,
7193 "{name}"
7194 );
7195 }
7196 assert_eq!(pool.opens(), 1);
7198 }
7199
7200 #[test]
7203 fn shared_path_serves_the_worktrees_of_a_bare_repository() {
7204 let bare_dir = tempfile::tempdir().unwrap();
7205 let repo = Repository::init_bare(bare_dir.path()).unwrap();
7206 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7207 repo.set_head("refs/heads/main").unwrap();
7208 let a_commit = repo.find_commit(a).unwrap();
7209 let c = empty_commit(&repo, None, &[&a_commit], "C");
7210 repo.reference("refs/remotes/origin/main", c, true, "origin main")
7211 .unwrap();
7212 drop(a_commit);
7213 let wts = tempfile::tempdir().unwrap();
7214 let wt = wts.path().join("feature");
7215 add_worktree(&repo, a, &wt, "feature");
7216
7217 let pool = shared_repo::RepoPool::new(4);
7218 for path in [wt.as_path(), bare_dir.path()] {
7219 assert_eq!(
7220 shared_repo::divergence(&pool, path),
7221 Some(folder_divergence(path)),
7222 "{}",
7223 path.display()
7224 );
7225 }
7226 assert_eq!(folder_divergence_shared(&pool, &wt).main_behind, Some(1));
7229 assert_eq!(pool.opens(), 1);
7230 }
7231
7232 #[test]
7237 fn shared_path_defers_to_discover_when_config_is_per_worktree() {
7238 let corpus = shared_corpus();
7239 let wt = corpus.wt("tracked");
7240 corpus
7241 .repo
7242 .config()
7243 .unwrap()
7244 .set_bool("extensions.worktreeConfig", true)
7245 .unwrap();
7246 std::fs::write(
7247 corpus.repo.path().join("worktrees/tracked/config.worktree"),
7248 "[branch \"tracked\"]\n\tmerge = refs/heads/elsewhere\n",
7249 )
7250 .unwrap();
7251 assert_eq!(folder_divergence(&wt).ahead_behind, None);
7253
7254 let pool = shared_repo::RepoPool::new(4);
7255 assert_eq!(shared_repo::divergence(&pool, &wt), None);
7256 assert_eq!(folder_divergence_shared(&pool, &wt), folder_divergence(&wt));
7257 }
7258
7259 #[test]
7265 fn shared_path_defers_to_discover_when_a_conditional_include_exists() {
7266 let corpus = shared_corpus();
7267 let common = std::fs::canonicalize(corpus.repo.path()).unwrap();
7268 std::fs::write(
7270 common.join("onbranch.cfg"),
7271 "[branch \"untracked\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7272 )
7273 .unwrap();
7274 std::fs::write(
7277 common.join("gitdir.cfg"),
7278 "[branch \"tracked\"]\n\tmerge = refs/heads/elsewhere\n",
7279 )
7280 .unwrap();
7281 let mut cfg = corpus.repo.config().unwrap();
7282 cfg.set_str("includeIf.onbranch:untracked.path", "onbranch.cfg")
7283 .unwrap();
7284 cfg.set_str(
7285 &format!("includeIf.gitdir:{}/worktrees/.path", common.display()),
7286 "gitdir.cfg",
7287 )
7288 .unwrap();
7289
7290 assert_eq!(
7293 folder_divergence(&corpus.wt("untracked")).ahead_behind,
7294 Some((1, 1))
7295 );
7296 assert_eq!(folder_divergence(&corpus.wt("tracked")).ahead_behind, None);
7297
7298 let pool = shared_repo::RepoPool::new(4);
7299 for name in ["untracked", "tracked"] {
7300 let wt = corpus.wt(name);
7301 assert_eq!(shared_repo::divergence(&pool, &wt), None, "{name}");
7302 assert_eq!(
7303 folder_divergence_shared(&pool, &wt),
7304 folder_divergence(&wt),
7305 "{name}"
7306 );
7307 }
7308 }
7309
7310 #[test]
7314 fn a_pooled_handle_sees_changes_made_between_uses() {
7315 let corpus = shared_corpus();
7316 let wt = corpus.wt("tracked");
7317 let pool = shared_repo::RepoPool::new(4);
7318 let ahead_behind = || shared_repo::divergence(&pool, &wt).unwrap().ahead_behind;
7319 assert_eq!(ahead_behind(), Some((1, 0)));
7320 let opens = pool.opens();
7321
7322 corpus
7324 .repo
7325 .reference(
7326 "refs/remotes/origin/tracked",
7327 corpus.origin_main,
7328 true,
7329 "fetch",
7330 )
7331 .unwrap();
7332 assert_eq!(ahead_behind(), Some((1, 1)));
7333
7334 let tip = corpus.repo.find_commit(corpus.tip).unwrap();
7336 empty_commit(&corpus.repo, Some("refs/heads/tracked"), &[&tip], "D");
7337 assert_eq!(ahead_behind(), Some((2, 1)));
7338
7339 corpus.track("tracked", "refs/heads/elsewhere");
7341 assert_eq!(ahead_behind(), None);
7342
7343 assert_eq!(pool.opens(), opens, "every answer came from one handle");
7344 assert_eq!(
7345 shared_repo::divergence(&pool, &wt),
7346 Some(folder_divergence(&wt))
7347 );
7348 }
7349
7350 #[tokio::test]
7353 async fn a_batch_of_linked_worktrees_opens_their_repo_once_per_concurrent_computation() {
7354 let main_dir = tempfile::tempdir().unwrap();
7355 let wts_dir = tempfile::tempdir().unwrap();
7356 let repo = diverging_repo(main_dir.path());
7357 let tip = repo.refname_to_id("refs/heads/main").unwrap();
7358 let paths: Vec<PathBuf> = (0..8)
7359 .map(|i| {
7360 let name = format!("w{i}");
7361 let path = wts_dir.path().join(&name);
7362 add_worktree(&repo, tip, &path, &name);
7363 let mut cfg = repo.config().unwrap();
7364 cfg.set_str(&format!("branch.{name}.remote"), "origin")
7365 .unwrap();
7366 cfg.set_str(&format!("branch.{name}.merge"), "refs/heads/main")
7367 .unwrap();
7368 path
7369 })
7370 .collect();
7371
7372 for (limit, most_opens) in [(1, 1), (4, 4)] {
7373 let coordinator = AheadBehindCoordinator::with_concurrency(limit);
7374 let pool = coordinator.pool();
7376 let reply = ahead_behind_results(&coordinator, paths.clone()).await;
7377
7378 assert!(
7379 (1..=most_opens).contains(&pool.opens()),
7380 "{} opens for 8 worktrees at concurrency {limit}",
7381 pool.opens()
7382 );
7383 for path in &paths {
7384 let entry = reply.get(path.display().to_string()).unwrap();
7385 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7386 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7387 }
7388 let weak = Arc::downgrade(&pool);
7390 drop(pool);
7391 assert!(weak.upgrade().is_none());
7392 }
7393 }
7394
7395 #[tokio::test]
7398 async fn ahead_behind_op_answers_every_worktree_shape_of_one_repo() {
7399 let corpus = shared_corpus();
7400 let svc = WorktreesService::new();
7401 let paths: Vec<String> = corpus
7402 .cases()
7403 .iter()
7404 .map(|(_, path)| path.display().to_string())
7405 .collect();
7406
7407 let reply = svc
7408 .handle("ahead-behind", json!({ "paths": paths }))
7409 .await
7410 .unwrap();
7411 let results = reply.get("results").unwrap();
7412
7413 let numbers = |name: &str| {
7414 let entry = results.get(corpus.wt(name).display().to_string())?;
7415 let field = |key| entry.get(key).and_then(Value::as_u64);
7416 Some((field("ahead"), field("behind"), field("main_behind")))
7417 };
7418 assert_eq!(numbers("tracked"), Some((Some(1), Some(0), Some(1))));
7419 assert_eq!(numbers("untracked"), Some((None, None, Some(1))));
7420 assert_eq!(numbers("on-default"), Some((Some(0), Some(1), None)));
7421 assert_eq!(numbers("dangling"), Some((None, None, Some(1))));
7422 for omitted in ["detached", "unborn", "deleted", "never-existed"] {
7423 assert_eq!(numbers(omitted), None, "{omitted}");
7424 }
7425 let main = results
7426 .get(corpus.main.display().to_string())
7427 .and_then(|entry| entry.get("ahead"))
7428 .and_then(Value::as_u64);
7429 assert_eq!(main, Some(1));
7430 }
7431
7432 #[tokio::test]
7433 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
7434 let diverging = tempfile::tempdir().unwrap();
7435 let _d = diverging_repo(diverging.path());
7436 let no_up = tempfile::tempdir().unwrap();
7437 let repo = init_repo(no_up.path());
7438 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7439 repo.set_head("refs/heads/main").unwrap();
7440
7441 let svc = WorktreesService::new();
7442 let diverging_path = diverging.path().display().to_string();
7443 let no_up_path = no_up.path().display().to_string();
7444 let reply = svc
7445 .handle(
7446 "ahead-behind",
7447 json!({ "paths": [&diverging_path, &no_up_path] }),
7448 )
7449 .await
7450 .unwrap();
7451 let results = reply.get("results").unwrap();
7452 let d = results.get(diverging_path.as_str()).unwrap();
7456 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
7457 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
7458 assert!(d.get("main_behind").is_none(), "{d:?}");
7459 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
7463
7464 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
7466 assert_eq!(empty.get("results"), Some(&json!({})));
7467 }
7468
7469 #[tokio::test]
7473 async fn credit_accept_outage_reaches_the_registry_without_dropping_a_live_window() {
7474 let svc = WorktreesService::new();
7475 svc.handle(
7476 "register",
7477 json!({ "key": "w", "folders": [], "repo": "x" }),
7478 )
7479 .await
7480 .unwrap();
7481
7482 svc.credit_accept_outage(Duration::from_secs(5));
7483
7484 assert_eq!(svc.registry.list().len(), 1);
7485 }
7486
7487 #[tokio::test]
7491 async fn concurrent_ahead_behind_requests_all_get_the_same_correct_answer() {
7492 let dir = tempfile::tempdir().unwrap();
7493 let _repo = diverging_repo(dir.path());
7494 let svc = WorktreesService::new();
7495 let path = dir.path().display().to_string();
7496
7497 let replies = futures::future::join_all(
7498 (0..16).map(|_| svc.handle("ahead-behind", json!({ "paths": [&path, &path] }))),
7499 )
7500 .await;
7501
7502 for reply in replies {
7503 let reply = reply.unwrap();
7504 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7505 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7506 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7507 }
7508 }
7509
7510 #[tokio::test]
7511 async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
7512 let dir = tempfile::tempdir().unwrap();
7513 let _repo = behind_main_no_upstream_repo(dir.path());
7514 let svc = WorktreesService::new();
7515 let path = dir.path().display().to_string();
7516 let reply = svc
7517 .handle("ahead-behind", json!({ "paths": [&path] }))
7518 .await
7519 .unwrap();
7520 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7521 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
7522 assert!(entry.get("ahead").is_none(), "{entry:?}");
7524 assert!(entry.get("behind").is_none(), "{entry:?}");
7525 }
7526
7527 #[tokio::test]
7528 async fn ahead_behind_op_flags_a_shallow_repository_and_only_that() {
7529 let svc = WorktreesService::new();
7532 let complete = tempfile::tempdir().unwrap();
7533 let _repo = diverging_repo(complete.path());
7534 let shallow = tempfile::tempdir().unwrap();
7535 let shallow_repo = diverging_repo(shallow.path());
7536 let base = shallow_repo
7538 .head()
7539 .unwrap()
7540 .peel_to_commit()
7541 .unwrap()
7542 .parent_id(0)
7543 .unwrap();
7544 std::fs::write(shallow_repo.path().join("shallow"), format!("{base}\n")).unwrap();
7545 assert!(shallow_repo.is_shallow());
7546
7547 let complete_path = complete.path().display().to_string();
7548 let shallow_path = shallow.path().display().to_string();
7549 let reply = svc
7550 .handle(
7551 "ahead-behind",
7552 json!({ "paths": [&complete_path, &shallow_path] }),
7553 )
7554 .await
7555 .unwrap();
7556 let results = reply.get("results").unwrap();
7557
7558 let shallow_entry = results.get(shallow_path.as_str()).unwrap();
7559 assert_eq!(shallow_entry.get("shallow"), Some(&json!(true)));
7560 assert_eq!(shallow_entry.get("ahead").and_then(Value::as_u64), Some(1));
7562 let complete_entry = results.get(complete_path.as_str()).unwrap();
7565 assert!(
7566 complete_entry.get("shallow").is_none(),
7567 "{complete_entry:?}"
7568 );
7569 assert_eq!(complete_entry.get("ahead").and_then(Value::as_u64), Some(1));
7570 }
7571
7572 #[tokio::test]
7577 async fn ahead_behind_op_flags_a_linked_worktree_of_a_shallow_clone() {
7578 let svc = WorktreesService::new();
7579 let corpus = shared_corpus();
7580 std::fs::write(
7581 corpus.repo.path().join("shallow"),
7582 format!("{}\n", corpus.tip),
7583 )
7584 .unwrap();
7585 let tracked = corpus.wt("tracked").display().to_string();
7586
7587 let reply = svc
7588 .handle("ahead-behind", json!({ "paths": [&tracked] }))
7589 .await
7590 .unwrap();
7591
7592 let entry = reply.get("results").unwrap().get(tracked.as_str()).unwrap();
7593 assert_eq!(entry.get("shallow"), Some(&json!(true)), "{entry:?}");
7594 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7595 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7596 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7597 }
7598
7599 #[tokio::test]
7600 async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
7601 let dir = tempfile::tempdir().unwrap();
7606 let repo = init_repo(dir.path());
7607 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
7608 let base_commit = repo.find_commit(base).unwrap();
7609
7610 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
7611 let m1_commit = repo.find_commit(m1).unwrap();
7612 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
7613 repo.reference("refs/remotes/origin/main", m2, true, "origin main")
7614 .unwrap();
7615
7616 let r = empty_commit(
7617 &repo,
7618 Some("refs/heads/release"),
7619 &[&base_commit],
7620 "release",
7621 );
7622 repo.reference("refs/remotes/origin/release", r, true, "origin release")
7623 .unwrap();
7624 drop(base_commit);
7625 drop(m1_commit);
7626
7627 repo.set_head("refs/heads/release").unwrap();
7628 let mut cfg = repo.config().unwrap();
7629 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
7630 .unwrap();
7631 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
7632 .unwrap();
7633 cfg.set_str("branch.release.remote", "origin").unwrap();
7634 cfg.set_str("branch.release.merge", "refs/heads/release")
7635 .unwrap();
7636
7637 let svc = WorktreesService::new();
7638 let path = dir.path().display().to_string();
7639 let reply = svc
7640 .handle("ahead-behind", json!({ "paths": [&path] }))
7641 .await
7642 .unwrap();
7643 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7644 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
7645 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
7646 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7647 }
7648
7649 #[tokio::test]
7650 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
7651 let dir = tempfile::tempdir().unwrap();
7653 let _repo = diverging_repo(dir.path());
7654 let svc = WorktreesService::new();
7655 svc.handle(
7656 "register",
7657 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7658 )
7659 .await
7660 .unwrap();
7661
7662 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7663 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7664 let main_wt = &worktrees[0];
7665 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
7668 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
7669 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
7670 }
7671
7672 #[tokio::test]
7673 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
7674 let dir = tempfile::tempdir().unwrap();
7679 let repo = init_repo(dir.path());
7680 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7681 repo.set_head("refs/heads/main").unwrap();
7682
7683 let svc = WorktreesService::new();
7684 svc.handle(
7685 "register",
7686 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7687 )
7688 .await
7689 .unwrap();
7690
7691 let before = svc.handle("tree", Value::Null).await.unwrap();
7692 let wt = &repos_of(&before)[0]["worktrees"][0];
7693 assert_eq!(
7694 wt.get("head_sha").and_then(Value::as_str),
7695 Some(a.to_string().as_str())
7696 );
7697
7698 let head = repo.find_commit(a).unwrap();
7701 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7702 let after = svc.handle("tree", Value::Null).await.unwrap();
7703 assert_eq!(
7704 repos_of(&after)[0]["worktrees"][0]
7705 .get("head_sha")
7706 .and_then(Value::as_str),
7707 Some(b.to_string().as_str())
7708 );
7709 assert_ne!(before, after, "a commit must be a visible snapshot delta");
7710 }
7711
7712 #[tokio::test]
7713 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7714 let dir = tempfile::tempdir().unwrap();
7717 init_repo(dir.path());
7718 let svc = WorktreesService::new();
7719 svc.handle(
7720 "register",
7721 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7722 )
7723 .await
7724 .unwrap();
7725 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726 assert!(wt.get("head_sha").is_none(), "{wt:?}");
7727 }
7728
7729 #[tokio::test]
7732 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7733 let dir = tempfile::tempdir().unwrap();
7739 let repo = diverging_repo(dir.path());
7740 let svc = WorktreesService::new();
7741 svc.handle(
7742 "register",
7743 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7744 )
7745 .await
7746 .unwrap();
7747
7748 let before = svc.handle("tree", Value::Null).await.unwrap();
7749 let head = repo.head().unwrap().target().unwrap();
7750 assert_ne!(
7751 repos_of(&before)[0]["worktrees"][0]
7752 .get("upstream_sha")
7753 .and_then(Value::as_str),
7754 Some(head.to_string().as_str()),
7755 "the fixture must start un-pushed for this to prove anything"
7756 );
7757
7758 simulate_push(&repo, head);
7760 let after = svc.handle("tree", Value::Null).await.unwrap();
7761 let wt = &repos_of(&after)[0]["worktrees"][0];
7762 assert_eq!(
7763 wt.get("upstream_sha").and_then(Value::as_str),
7764 Some(head.to_string().as_str())
7765 );
7766 assert_eq!(
7769 wt.get("head_sha").and_then(Value::as_str),
7770 repos_of(&before)[0]["worktrees"][0]
7771 .get("head_sha")
7772 .and_then(Value::as_str)
7773 );
7774 assert_ne!(before, after, "a push must be a visible snapshot delta");
7775 }
7776
7777 #[tokio::test]
7778 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7779 let dir = tempfile::tempdir().unwrap();
7783 let repo = init_repo(dir.path());
7784 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7785 repo.set_head("refs/heads/main").unwrap();
7786 let svc = WorktreesService::new();
7787 svc.handle(
7788 "register",
7789 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7790 )
7791 .await
7792 .unwrap();
7793 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7794 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7795 assert!(wt.get("head_sha").is_some(), "{wt:?}");
7797 }
7798
7799 fn simulate_default_branch_fetch(repo: &Repository) -> git2::Oid {
7804 let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7805 let parent = repo.find_commit(tip).unwrap();
7806 let next = empty_commit(repo, None, &[&parent], "fetched");
7807 repo.reference("refs/remotes/origin/main", next, true, "fetch")
7808 .unwrap();
7809 next
7810 }
7811
7812 #[test]
7813 fn default_branch_tip_resolves_the_remote_default_branch_or_nothing() {
7814 let dir = tempfile::tempdir().unwrap();
7816 let repo = behind_main_no_upstream_repo(dir.path());
7817 let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7818 assert_eq!(
7819 default_branch_tip(&repo),
7820 Some(("refs/remotes/origin/main".to_string(), tip))
7821 );
7822
7823 let develop = empty_commit(&repo, None, &[], "develop");
7827 repo.reference("refs/remotes/origin/develop", develop, true, "develop")
7828 .unwrap();
7829 repo.reference_symbolic(
7830 "refs/remotes/origin/HEAD",
7831 "refs/remotes/origin/develop",
7832 true,
7833 "set-head",
7834 )
7835 .unwrap();
7836 assert_eq!(
7837 default_branch_tip(&repo),
7838 Some(("refs/remotes/origin/develop".to_string(), develop))
7839 );
7840
7841 let bare = tempfile::tempdir().unwrap();
7843 let none = init_repo(bare.path());
7844 empty_commit(&none, Some("refs/heads/main"), &[], "A");
7845 assert_eq!(default_branch_tip(&none), None);
7846 }
7847
7848 #[test]
7849 fn main_behind_is_computed_against_the_published_tip() {
7850 let dir = tempfile::tempdir().unwrap();
7854 let repo = behind_main_no_upstream_repo(dir.path());
7855 assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
7856 let before = default_branch_tip(&repo).unwrap().1;
7857
7858 let after = simulate_default_branch_fetch(&repo);
7859 assert_ne!(before, after);
7860 assert_eq!(default_branch_tip(&repo).unwrap().1, after);
7861 assert_eq!(folder_divergence(dir.path()).main_behind, Some(2));
7862 }
7863
7864 #[tokio::test]
7865 async fn tree_snapshot_carries_main_sha_so_a_default_branch_fetch_is_a_real_delta() {
7866 let dir = tempfile::tempdir().unwrap();
7872 let repo = behind_main_no_upstream_repo(dir.path());
7873 let svc = WorktreesService::new();
7874 svc.handle(
7875 "register",
7876 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7877 )
7878 .await
7879 .unwrap();
7880
7881 let before = svc.handle("tree", Value::Null).await.unwrap();
7882 let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7883 assert_eq!(
7884 repos_of(&before)[0].get("main_sha").and_then(Value::as_str),
7885 Some(tip.to_string().as_str())
7886 );
7887
7888 let fetched = simulate_default_branch_fetch(&repo);
7889 let after = svc.handle("tree", Value::Null).await.unwrap();
7890 assert_eq!(
7891 repos_of(&after)[0].get("main_sha").and_then(Value::as_str),
7892 Some(fetched.to_string().as_str())
7893 );
7894 assert_eq!(
7897 repos_of(&before)[0]["worktrees"],
7898 repos_of(&after)[0]["worktrees"]
7899 );
7900 assert_ne!(
7901 before, after,
7902 "a default-branch fetch must be a snapshot delta"
7903 );
7904 }
7905
7906 #[tokio::test]
7907 async fn tree_snapshot_omits_main_sha_without_a_default_branch() {
7908 let dir = tempfile::tempdir().unwrap();
7911 let repo = init_repo(dir.path());
7912 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7913 repo.set_head("refs/heads/main").unwrap();
7914 let svc = WorktreesService::new();
7915 svc.handle(
7916 "register",
7917 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7918 )
7919 .await
7920 .unwrap();
7921 let tree = svc.handle("tree", Value::Null).await.unwrap();
7922 assert!(repos_of(&tree)[0].get("main_sha").is_none(), "{tree:?}");
7923 }
7924
7925 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7935 let guard = shim_lock();
7936 let path = dir.join("fake-gh");
7937 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7938 (path, guard)
7939 }
7940
7941 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7946 let guard = shim_lock();
7947 let path = dir.join("fake-gh");
7948 let counter = dir.join("gh-calls");
7949 write_exec_script(
7950 &path,
7951 &format!(
7952 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7953 counter = counter.display()
7954 ),
7955 );
7956 (path, guard, counter)
7957 }
7958
7959 fn gh_spawn_count(counter: &Path) -> usize {
7962 std::fs::read(counter).map_or(0, |b| b.len())
7963 }
7964
7965 fn counted_gh_records(log: &Path) -> usize {
7970 std::fs::read_to_string(log)
7971 .unwrap_or_default()
7972 .lines()
7973 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7974 .count()
7975 }
7976
7977 fn github_repo(dir: &Path) -> Repository {
7979 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7980 }
7981
7982 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7985 let repo = init_repo(dir);
7986 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7987 repo.set_head("refs/heads/main").unwrap();
7988 repo.remote("origin", url).unwrap();
7989 repo
7990 }
7991
7992 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7997 PrBadge {
7998 number,
7999 is_draft: false,
8000 checks: PrCheckState::Pending,
8001 url: "u".into(),
8002 head_oid: head_oid.to_string(),
8003 }
8004 }
8005
8006 fn pr(badge: PrBadge) -> PrResolution {
8008 PrResolution::Pr(badge)
8009 }
8010
8011 #[test]
8012 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
8013 let snapshot = json!({"repos":[
8014 {
8015 "main_repo":"omni-dev",
8016 "github":{"owner":"rust-works","name":"omni-dev"},
8017 "root":"/r",
8018 "polling_enabled":true,
8021 "worktrees":[
8023 {"path":"/r","branch":"main","is_main":true,"open":true},
8024 {"path":"/w1","branch":"main","is_main":false,"open":true},
8025 {"path":"/w2","branch":"feature","is_main":false,"open":true},
8026 {"path":"/w3","is_main":false,"open":true}
8028 ]
8029 },
8030 {
8031 "main_repo":"local","root":"/l",
8033 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
8034 }
8035 ]});
8036 let targets = pr_targets_from_snapshot(&snapshot);
8037 assert_eq!(
8038 targets,
8039 vec![
8040 PrTarget {
8041 owner: "rust-works".into(),
8042 name: "omni-dev".into(),
8043 branch: "feature".into()
8044 },
8045 PrTarget {
8046 owner: "rust-works".into(),
8047 name: "omni-dev".into(),
8048 branch: "main".into()
8049 },
8050 ]
8051 );
8052 }
8053
8054 #[test]
8055 fn pr_targets_from_snapshot_is_empty_without_repos() {
8056 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
8057 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
8058 }
8059
8060 #[test]
8061 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
8062 for github in [
8065 json!({}),
8066 json!({"owner": "o"}),
8067 json!({"owner": 1, "name": 2}),
8068 ] {
8069 let snapshot = json!({"repos":[{
8070 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
8071 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8072 }]});
8073 assert!(
8074 pr_targets_from_snapshot(&snapshot).is_empty(),
8075 "{snapshot:?}"
8076 );
8077 }
8078 }
8079
8080 #[test]
8081 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
8082 for repo in [
8086 json!({
8087 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8088 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8089 }),
8090 json!({
8091 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8092 "polling_enabled":false,
8093 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8094 }),
8095 ] {
8096 let snapshot = json!({ "repos": [repo] });
8097 assert!(
8098 pr_targets_from_snapshot(&snapshot).is_empty(),
8099 "not-polled repo must yield no targets: {snapshot:?}"
8100 );
8101 }
8102 let enabled = json!({"repos":[{
8104 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8105 "polling_enabled":true,
8106 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8107 }]});
8108 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
8109 }
8110
8111 #[test]
8112 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
8113 let backoff = Duration::from_secs(600);
8114 assert!(pr_should_fetch(false, None, backoff));
8116 assert!(!pr_should_fetch(
8119 false,
8120 Some(Duration::from_secs(1)),
8121 backoff
8122 ));
8123 assert!(pr_should_fetch(false, Some(backoff), backoff));
8125 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
8126 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
8130 assert!(pr_should_fetch(
8131 true,
8132 Some(Duration::from_millis(1)),
8133 backoff
8134 ));
8135 }
8136
8137 #[test]
8138 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
8139 let base = Duration::from_secs(10);
8140 let fresh = Some(Duration::ZERO);
8141 let stale = Some(PENDING_FAST_WINDOW);
8142 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
8145 assert_eq!(
8146 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
8147 base
8148 );
8149 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
8152 assert_eq!(
8153 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
8154 PENDING_MAX_INTERVAL
8155 );
8156 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
8159 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
8161 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
8162 assert_eq!(
8164 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
8165 MAX_PR_POLL_INTERVAL
8166 );
8167 assert_eq!(
8168 next_pr_poll_delay(Duration::MAX, base, false, None),
8169 MAX_PR_POLL_INTERVAL
8170 );
8171 }
8172
8173 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
8175 PrWatch {
8176 target: PrTarget {
8177 owner: "rust-works".into(),
8178 name: "omni-dev".into(),
8179 branch: branch.into(),
8180 },
8181 upstream_sha: upstream.map(str::to_string),
8182 }
8183 }
8184
8185 #[test]
8186 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
8187 let a = watch("a", Some("111"));
8188 let b = watch("b", Some("222"));
8189 let ab = [a.clone(), b.clone()];
8190 let just_a = std::slice::from_ref(&a);
8191 let just_b = std::slice::from_ref(&b);
8192 assert!(!pr_watch_grew(&ab, &ab));
8194 assert!(pr_watch_grew(just_a, &ab));
8196 assert!(!pr_watch_grew(&ab, just_a));
8198 assert!(pr_watch_grew(&[], just_a));
8200 let a_pushed = [watch("a", Some("999"))];
8202 assert!(pr_watch_grew(just_a, &a_pushed));
8203 assert!(pr_watch_grew(just_a, just_b));
8205 }
8206
8207 #[test]
8208 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
8209 let base = Duration::from_secs(10);
8210 let over = RateLimitSnapshot {
8211 graphql: Some(rl_resource(90)),
8212 core: Some(rl_resource(3)),
8213 search: None,
8214 };
8215 let under = RateLimitSnapshot {
8216 graphql: Some(rl_resource(50)),
8217 core: Some(rl_resource(3)),
8218 search: None,
8219 };
8220 assert_eq!(budget_throttled_delay(base, None), base);
8222 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
8224 assert_eq!(
8226 budget_throttled_delay(base, Some(&over)),
8227 BUDGET_THROTTLE_INTERVAL
8228 );
8229 let long = BUDGET_THROTTLE_INTERVAL * 2;
8231 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
8232 }
8233
8234 #[test]
8235 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
8236 let target = PrTarget {
8240 owner: "rust-works".into(),
8241 name: "omni-dev".into(),
8242 branch: "main".into(),
8243 };
8244 let badge = PrResolution::Pr(PrBadge {
8245 number: 1337,
8246 is_draft: true,
8247 checks: PrCheckState::Pending,
8248 url: "http://x/1337".into(),
8249 head_oid: "deadbeef".into(),
8250 });
8251 let watched = vec![watch("main", Some("abc"))];
8252 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
8253 .unwrap()
8254 .with_timezone(&Utc);
8255 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
8256
8257 let json = serde_json::to_vec(&prefs).unwrap();
8258 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
8259 assert_eq!(back, prefs);
8260 assert_eq!(back.polled_at, Some(polled_at));
8261 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
8262 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
8264 }
8265
8266 #[test]
8267 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
8268 let target = PrTarget {
8272 owner: "rust-works".into(),
8273 name: "omni-dev".into(),
8274 branch: "feature".into(),
8275 };
8276 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
8277 let json = serde_json::to_vec(&prefs).unwrap();
8278 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
8279 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
8280 assert_eq!(
8281 back.entries[0].resolution.clone().into_resolution(),
8282 PrResolution::NoPr
8283 );
8284 }
8285
8286 #[test]
8287 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
8288 let dir = tempfile::tempdir().unwrap();
8293 let path = dir.path().join("pr-cache.json");
8294 let target = PrTarget {
8295 owner: "rust-works".into(),
8296 name: "omni-dev".into(),
8297 branch: "main".into(),
8298 };
8299 let mut prefs = pr_cache_prefs_from(
8300 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
8301 &[watch("main", None)],
8302 Utc::now(),
8303 );
8304 prefs.polled_at = None;
8305 write_pr_cache(&path, &prefs).unwrap();
8306
8307 let svc = WorktreesService::new();
8308 svc.load_pr_cache(path);
8309 assert!(
8310 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
8311 "the badge itself must still restore"
8312 );
8313 assert!(
8314 svc.pr_warm_start
8315 .lock()
8316 .unwrap_or_else(PoisonError::into_inner)
8317 .is_none(),
8318 "no poll time means a cold start, not a trusted warm one"
8319 );
8320 }
8321
8322 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
8326 tracing::subscriber::set_default(
8327 tracing_subscriber::fmt()
8328 .with_max_level(tracing::Level::WARN)
8329 .with_writer(std::io::sink)
8330 .finish(),
8331 )
8332 }
8333
8334 #[test]
8335 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
8336 let _trace = warn_subscriber();
8340 let dir = tempfile::tempdir().unwrap();
8341 let corrupt = dir.path().join("pr-cache.json");
8342 std::fs::write(&corrupt, b"not json").unwrap();
8343 let svc = WorktreesService::new();
8344 svc.load_pr_cache(corrupt.clone());
8345 assert!(svc.pr_cache.entries().is_empty());
8346 assert_eq!(
8347 svc.pr_cache_path
8348 .lock()
8349 .unwrap_or_else(PoisonError::into_inner)
8350 .as_deref(),
8351 Some(corrupt.as_path()),
8352 "the path must be stored even when the load fails, so persistence recovers"
8353 );
8354
8355 let svc = WorktreesService::new();
8358 svc.load_pr_cache(dir.path().to_path_buf());
8359 assert!(svc.pr_cache.entries().is_empty());
8360 }
8361
8362 #[test]
8363 fn persist_pr_cache_swallows_a_write_failure() {
8364 let _trace = warn_subscriber();
8368 let dir = tempfile::tempdir().unwrap();
8369 let blocker = dir.path().join("blocker");
8370 std::fs::write(&blocker, b"").unwrap();
8371 let path = blocker.join("pr-cache.json");
8373 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
8374 assert!(!path.exists());
8375 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
8378 }
8379
8380 #[tokio::test]
8381 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
8382 let dir = tempfile::tempdir().unwrap();
8383 let repo = github_repo(dir.path());
8384 let head = repo.head().unwrap().target().unwrap().to_string();
8385 let svc = WorktreesService::new();
8386 svc.handle(
8387 "register",
8388 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8389 )
8390 .await
8391 .unwrap();
8392 svc.registry.set_polling("rust-works", "omni-dev", true);
8395
8396 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8399 assert!(wt.get("pr").is_none(), "{wt:?}");
8400 assert!(wt.get("pr_none").is_none(), "{wt:?}");
8401
8402 let mut badges = HashMap::new();
8404 badges.insert(
8405 PrTarget {
8406 owner: "rust-works".into(),
8407 name: "omni-dev".into(),
8408 branch: "main".into(),
8409 },
8410 pr(pending_badge(1337, &head)),
8411 );
8412 assert!(svc.pr_cache.replace(badges));
8413
8414 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8415 assert_eq!(wt["pr"]["number"], json!(1337));
8416 assert_eq!(wt["pr"]["checks"], json!("pending"));
8417 assert_eq!(wt["pr"]["isDraft"], json!(false));
8419 assert!(wt.get("pr_none").is_none(), "{wt:?}");
8421 }
8422
8423 #[tokio::test]
8424 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
8425 let dir = tempfile::tempdir().unwrap();
8430 let repo = github_repo(dir.path());
8431 let head = repo.head().unwrap().target().unwrap();
8432 repo.set_head_detached(head).unwrap();
8433
8434 let svc = WorktreesService::new();
8435 svc.handle(
8436 "register",
8437 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8438 )
8439 .await
8440 .unwrap();
8441 svc.registry.set_polling("rust-works", "omni-dev", true);
8444 let mut badges = HashMap::new();
8447 badges.insert(
8448 PrTarget {
8449 owner: "rust-works".into(),
8450 name: "omni-dev".into(),
8451 branch: "main".into(),
8452 },
8453 pr(pending_badge(1, &head.to_string())),
8454 );
8455 svc.pr_cache.replace(badges);
8456
8457 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8458 assert!(wt.get("branch").is_none(), "{wt:?}");
8459 assert_eq!(
8461 wt.get("head_sha").and_then(Value::as_str),
8462 Some(head.to_string().as_str())
8463 );
8464 assert!(wt.get("pr").is_none(), "{wt:?}");
8465 assert!(wt.get("pr_none").is_none(), "{wt:?}");
8467 }
8468
8469 #[tokio::test]
8470 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
8471 let dir = tempfile::tempdir().unwrap();
8472 github_repo(dir.path());
8473 let svc = WorktreesService::new();
8474 svc.handle(
8475 "register",
8476 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8477 )
8478 .await
8479 .unwrap();
8480 svc.registry.set_polling("rust-works", "omni-dev", true);
8483 let mut badges = HashMap::new();
8485 badges.insert(
8486 PrTarget {
8487 owner: "rust-works".into(),
8488 name: "omni-dev".into(),
8489 branch: "other".into(),
8490 },
8491 pr(pending_badge(1, "irrelevant")),
8492 );
8493 svc.pr_cache.replace(badges);
8494 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8495 assert!(wt.get("pr").is_none(), "{wt:?}");
8496 assert!(wt.get("pr_none").is_none(), "{wt:?}");
8498 }
8499
8500 #[tokio::test]
8501 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
8502 let dir = tempfile::tempdir().unwrap();
8506 github_repo(dir.path());
8507 let svc = WorktreesService::new();
8508 svc.handle(
8509 "register",
8510 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8511 )
8512 .await
8513 .unwrap();
8514 svc.registry.set_polling("rust-works", "omni-dev", true);
8517
8518 let mut resolutions = HashMap::new();
8519 resolutions.insert(
8520 PrTarget {
8521 owner: "rust-works".into(),
8522 name: "omni-dev".into(),
8523 branch: "main".into(),
8524 },
8525 PrResolution::NoPr,
8526 );
8527 assert!(svc.pr_cache.replace(resolutions));
8528
8529 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8530 assert_eq!(wt["pr_none"], json!(true));
8531 assert!(wt.get("pr").is_none(), "{wt:?}");
8533 }
8534
8535 #[tokio::test]
8536 async fn a_commit_does_not_drop_a_negative_resolution() {
8537 let dir = tempfile::tempdir().unwrap();
8542 let repo = github_repo(dir.path());
8543 let first = repo.head().unwrap().target().unwrap();
8544
8545 let svc = WorktreesService::new();
8546 svc.handle(
8547 "register",
8548 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8549 )
8550 .await
8551 .unwrap();
8552 svc.registry.set_polling("rust-works", "omni-dev", true);
8555
8556 let mut resolutions = HashMap::new();
8557 resolutions.insert(
8558 PrTarget {
8559 owner: "rust-works".into(),
8560 name: "omni-dev".into(),
8561 branch: "main".into(),
8562 },
8563 PrResolution::NoPr,
8564 );
8565 svc.pr_cache.replace(resolutions);
8566
8567 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8568 assert_eq!(wt["pr_none"], json!(true));
8569
8570 let head = repo.find_commit(first).unwrap();
8572 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8573
8574 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8575 assert_eq!(
8576 wt["pr_none"],
8577 json!(true),
8578 "a local commit must not drop the negative"
8579 );
8580 }
8581
8582 #[tokio::test]
8583 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
8584 let bin_dir = tempfile::tempdir().unwrap();
8588 let marker = bin_dir.path().join("spawned");
8589 let fake = bin_dir.path().join("fake-gh");
8590 std::fs::write(
8591 &fake,
8592 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
8593 )
8594 .unwrap();
8595 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
8596 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
8597 std::fs::set_permissions(&fake, perms).unwrap();
8598
8599 let svc = WorktreesService::new();
8600 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
8601 tokio::time::sleep(Duration::from_millis(200)).await;
8602 svc.shutdown().await;
8603 assert!(
8604 !marker.exists(),
8605 "the poller must not spawn gh with no windows registered"
8606 );
8607 }
8608
8609 #[tokio::test]
8610 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
8611 let dir = tempfile::tempdir().unwrap();
8614 let repo = github_repo(dir.path());
8615 let head = repo.head().unwrap().target().unwrap().to_string();
8616 let bin_dir = tempfile::tempdir().unwrap();
8617 let fake = bin_dir.path().join("fake-gh");
8618 std::fs::write(
8620 &fake,
8621 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
8622 )
8623 .unwrap();
8624 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
8625 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
8626 std::fs::set_permissions(&fake, perms).unwrap();
8627
8628 let svc = WorktreesService::new();
8629 svc.handle(
8630 "register",
8631 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8632 )
8633 .await
8634 .unwrap();
8635 svc.registry.set_polling("rust-works", "omni-dev", true);
8638 let mut seeded = HashMap::new();
8640 seeded.insert(
8641 PrTarget {
8642 owner: "rust-works".into(),
8643 name: "omni-dev".into(),
8644 branch: "main".into(),
8645 },
8646 pr(pending_badge(7, &head)),
8647 );
8648 svc.pr_cache.replace(seeded);
8649
8650 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
8651 tokio::time::sleep(Duration::from_millis(200)).await;
8652
8653 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8656 assert_eq!(wt["pr"]["number"], json!(7));
8657 assert!(wt.get("pr_none").is_none(), "{wt:?}");
8658 svc.shutdown().await;
8659 }
8660
8661 #[tokio::test]
8662 #[allow(clippy::await_holding_lock)]
8669 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
8670 let dir = tempfile::tempdir().unwrap();
8676 github_repo(dir.path());
8677 let bin_dir = tempfile::tempdir().unwrap();
8678 let (fake, _shim) = fake_gh(
8679 bin_dir.path(),
8680 r#"{"data":{"r0":{"b0":{
8681 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8682 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8683 ]}}},
8684 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
8685 }}}}"#,
8686 );
8687
8688 let svc = WorktreesService::new();
8689 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8691 tokio::time::sleep(Duration::from_millis(150)).await;
8692
8693 svc.handle(
8695 "register",
8696 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8697 )
8698 .await
8699 .unwrap();
8700 svc.registry.set_polling("rust-works", "omni-dev", true);
8703
8704 let badge = tokio::time::timeout(Duration::from_secs(30), async {
8708 loop {
8709 if let Some(PrResolution::Pr(badge)) =
8710 svc.pr_cache.get("rust-works", "omni-dev", "main")
8711 {
8712 return badge;
8713 }
8714 tokio::time::sleep(Duration::from_millis(25)).await;
8715 }
8716 })
8717 .await
8718 .expect("a window opening must wake the poller out of its idle backoff");
8719 assert_eq!(badge.number, 99);
8720 svc.shutdown().await;
8721 }
8722
8723 #[tokio::test]
8724 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
8725 let dir = tempfile::tempdir().unwrap();
8732 let repo = github_repo(dir.path());
8733 let first = repo.head().unwrap().target().unwrap();
8734
8735 let svc = WorktreesService::new();
8736 svc.handle(
8737 "register",
8738 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8739 )
8740 .await
8741 .unwrap();
8742 svc.registry.set_polling("rust-works", "omni-dev", true);
8745
8746 let mut badges = HashMap::new();
8748 badges.insert(
8749 PrTarget {
8750 owner: "rust-works".into(),
8751 name: "omni-dev".into(),
8752 branch: "main".into(),
8753 },
8754 pr(PrBadge {
8755 number: 1337,
8756 is_draft: false,
8757 checks: PrCheckState::Success,
8758 url: "u".into(),
8759 head_oid: first.to_string(),
8760 }),
8761 );
8762 svc.pr_cache.replace(badges);
8763
8764 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8765 assert_eq!(
8766 wt["pr"]["checks"],
8767 json!("success"),
8768 "green for its own commit"
8769 );
8770
8771 let head = repo.find_commit(first).unwrap();
8773 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8774
8775 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8776 assert_eq!(
8777 wt["pr"]["checks"],
8778 json!("pending"),
8779 "the previous commit's ✓ must not stand after a new commit"
8780 );
8781 assert_eq!(wt["pr"]["number"], json!(1337));
8784 }
8785
8786 #[test]
8787 fn is_stale_for_compares_the_commit_the_verdict_describes() {
8788 let badge = pending_badge(1, "aaa");
8789 assert!(!badge.is_stale_for(Some("aaa")));
8790 assert!(badge.is_stale_for(Some("bbb")));
8791 assert!(!badge.is_stale_for(None));
8793 }
8794
8795 #[test]
8796 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
8797 let snap = |sha: &str| {
8802 json!({"repos":[{
8803 "main_repo":"omni-dev",
8804 "github":{"owner":"rust-works","name":"omni-dev"},
8805 "root":"/r",
8806 "polling_enabled":true,
8807 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
8808 }]})
8809 };
8810 let before = pr_watch_from_snapshot(&snap("aaa"));
8811 let after = pr_watch_from_snapshot(&snap("bbb"));
8812 assert_eq!(before.len(), 1);
8813 assert_eq!(before[0].target, after[0].target);
8814 assert_eq!(before, after);
8816 assert!(!pr_watch_grew(&before, &after));
8817 }
8818
8819 #[test]
8820 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
8821 let snap = |upstream: &str| {
8825 json!({"repos":[{
8826 "main_repo":"omni-dev",
8827 "github":{"owner":"rust-works","name":"omni-dev"},
8828 "root":"/r",
8829 "polling_enabled":true,
8830 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
8831 "upstream_sha":upstream,"is_main":true,"open":true}]
8832 }]})
8833 };
8834 let before = pr_watch_from_snapshot(&snap("aaa"));
8835 let after = pr_watch_from_snapshot(&snap("bbb"));
8836 assert_eq!(before.len(), 1);
8838 assert_eq!(before[0].target, after[0].target);
8839 assert_ne!(before, after);
8840 assert!(pr_watch_grew(&before, &after));
8841 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8843 assert!(!pr_watch_grew(
8844 &before,
8845 &pr_watch_from_snapshot(&snap("aaa"))
8846 ));
8847 }
8848
8849 #[test]
8850 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8851 let snap = json!({"repos":[{
8854 "main_repo":"omni-dev",
8855 "github":{"owner":"rust-works","name":"omni-dev"},
8856 "root":"/r",
8857 "polling_enabled":true,
8858 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8859 }]});
8860 let watch = pr_watch_from_snapshot(&snap);
8861 assert_eq!(watch.len(), 1);
8862 assert_eq!(watch[0].upstream_sha, None);
8863 }
8864
8865 #[test]
8866 fn start_pr_poller_is_a_noop_outside_a_runtime() {
8867 let svc = WorktreesService::new();
8868 svc.start_pr_poller();
8869 assert!(svc
8870 .poller
8871 .lock()
8872 .unwrap_or_else(PoisonError::into_inner)
8873 .is_none());
8874 }
8875
8876 #[tokio::test]
8877 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8878 let svc = WorktreesService::new();
8879 svc.start_pr_poller_with(
8880 Duration::from_millis(50),
8881 Duration::from_millis(10),
8882 PathBuf::from("/bin/true"),
8883 );
8884 let token = svc
8885 .poller
8886 .lock()
8887 .unwrap_or_else(PoisonError::into_inner)
8888 .as_ref()
8889 .map(|t| t.token.clone())
8890 .expect("poller started");
8891
8892 token.cancel();
8895 svc.start_pr_poller_with(
8896 Duration::from_millis(50),
8897 Duration::from_millis(10),
8898 PathBuf::from("/bin/true"),
8899 );
8900 assert!(svc
8901 .poller
8902 .lock()
8903 .unwrap_or_else(PoisonError::into_inner)
8904 .as_ref()
8905 .is_some_and(|t| t.token.is_cancelled()));
8906
8907 svc.shutdown().await;
8908 assert!(svc
8909 .poller
8910 .lock()
8911 .unwrap_or_else(PoisonError::into_inner)
8912 .is_none());
8913 }
8914
8915 fn rl_resource(used: u64) -> RateLimitResource {
8919 RateLimitResource {
8920 used,
8921 limit: 100,
8922 remaining: 100 - used,
8923 percent: used as f64,
8924 reset: 0,
8925 }
8926 }
8927
8928 #[test]
8929 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8930 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8931 graphql: Some(rl_resource(graphql)),
8932 core: Some(rl_resource(core)),
8933 search: None,
8934 };
8935 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8937 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8939 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8941 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8943 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8945 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8947 }
8948
8949 #[test]
8950 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8951 let svc = WorktreesService::new();
8952 svc.start_rate_limit_poller();
8953 assert!(svc
8954 .rate_limit_poller
8955 .lock()
8956 .unwrap_or_else(PoisonError::into_inner)
8957 .is_none());
8958 }
8959
8960 #[tokio::test]
8961 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8962 let svc = WorktreesService::new();
8963 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8964 let token = svc
8965 .rate_limit_poller
8966 .lock()
8967 .unwrap_or_else(PoisonError::into_inner)
8968 .as_ref()
8969 .map(|t| t.token.clone())
8970 .expect("poller started");
8971
8972 token.cancel();
8975 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8976 assert!(svc
8977 .rate_limit_poller
8978 .lock()
8979 .unwrap_or_else(PoisonError::into_inner)
8980 .as_ref()
8981 .is_some_and(|t| t.token.is_cancelled()));
8982
8983 svc.shutdown().await;
8984 assert!(svc
8985 .rate_limit_poller
8986 .lock()
8987 .unwrap_or_else(PoisonError::into_inner)
8988 .is_none());
8989 }
8990
8991 #[tokio::test]
8992 #[allow(clippy::await_holding_lock)]
8994 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8995 let bin_dir = tempfile::tempdir().unwrap();
8996 let (fake, _shim) = fake_gh(
8997 bin_dir.path(),
8998 r#"{"resources":{
8999 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
9000 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
9001 }}"#,
9002 );
9003 let svc = WorktreesService::new();
9004 svc.registry.set_polling("rust-works", "omni-dev", true);
9007 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
9008
9009 let snap = tokio::time::timeout(Duration::from_secs(30), async {
9012 loop {
9013 if let Some(snap) = svc.rate_limit_cache.get() {
9014 return snap;
9015 }
9016 tokio::time::sleep(Duration::from_millis(25)).await;
9017 }
9018 })
9019 .await
9020 .expect("poller should populate the cache through the fake gh");
9021 assert_eq!(snap.graphql.unwrap().used, 4100);
9022 assert_eq!(snap.core.unwrap().used, 27);
9023
9024 assert!(svc.rate_limit_cache().get().is_some());
9026
9027 svc.shutdown().await;
9028 assert!(svc
9029 .rate_limit_poller
9030 .lock()
9031 .unwrap_or_else(PoisonError::into_inner)
9032 .is_none());
9033 }
9034
9035 #[tokio::test]
9036 #[allow(clippy::await_holding_lock)]
9038 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
9039 let bin_dir = tempfile::tempdir().unwrap();
9042 let (fake, _shim, counter) = counting_fake_gh(
9043 bin_dir.path(),
9044 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
9045 );
9046 let svc = WorktreesService::new();
9047 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
9048
9049 tokio::time::sleep(Duration::from_millis(300)).await;
9051 assert_eq!(
9052 gh_spawn_count(&counter),
9053 0,
9054 "idle daemon must not poll (#1389, fix 8b)"
9055 );
9056 assert!(svc.rate_limit_cache.get().is_none());
9057
9058 svc.registry.set_polling("rust-works", "omni-dev", true);
9060 tokio::time::timeout(Duration::from_secs(30), async {
9061 loop {
9062 if svc.rate_limit_cache.get().is_some() {
9063 return;
9064 }
9065 tokio::time::sleep(Duration::from_millis(25)).await;
9066 }
9067 })
9068 .await
9069 .expect("an active lease should resume polling");
9070 assert!(gh_spawn_count(&counter) >= 1);
9071 svc.shutdown().await;
9072 }
9073
9074 #[tokio::test]
9075 async fn rate_limit_poller_survives_a_failing_gh() {
9076 let svc = WorktreesService::new();
9080 svc.registry.set_polling("rust-works", "omni-dev", true);
9081 svc.start_rate_limit_poller_with(
9082 Duration::from_millis(20),
9083 PathBuf::from("/no/such/gh/xyzzy"),
9084 );
9085 tokio::time::sleep(Duration::from_millis(150)).await;
9087 assert!(svc.rate_limit_cache.get().is_none());
9088 assert!(
9089 svc.rate_limit_poller
9090 .lock()
9091 .unwrap_or_else(PoisonError::into_inner)
9092 .is_some(),
9093 "the loop must survive a failing gh, not panic out"
9094 );
9095 svc.shutdown().await;
9096 }
9097
9098 #[test]
9099 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
9100 let svc = WorktreesService::new();
9101 let items = svc.menu().items;
9103 assert!(
9104 !items
9105 .iter()
9106 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
9107 "no github line before the first poll"
9108 );
9109
9110 svc.rate_limit_cache.replace(RateLimitSnapshot {
9112 graphql: Some(rl_resource(82)),
9113 core: Some(rl_resource(3)),
9114 search: None,
9115 });
9116 let items = svc.menu().items;
9117 assert!(
9118 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
9119 "expected the github line first, got {items:?}"
9120 );
9121 assert!(
9122 matches!(items.get(1), Some(MenuItem::Separator)),
9123 "expected a separator after the github line"
9124 );
9125 }
9126
9127 #[tokio::test]
9128 #[allow(clippy::await_holding_lock)]
9130 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
9131 let dir = tempfile::tempdir().unwrap();
9132 github_repo(dir.path());
9133 let bin_dir = tempfile::tempdir().unwrap();
9134 let (fake, _shim) = fake_gh(
9137 bin_dir.path(),
9138 r#"{"data":{"r0":{"b0":{
9139 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9140 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9141 ]}}},
9142 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9143 }}}}"#,
9144 );
9145 let svc = WorktreesService::new();
9146 svc.handle(
9147 "register",
9148 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9149 )
9150 .await
9151 .unwrap();
9152 svc.registry.set_polling("rust-works", "omni-dev", true);
9155 svc.start_pr_poller_with(
9156 Duration::from_millis(50),
9157 Duration::from_millis(10),
9158 fake.clone(),
9159 );
9160
9161 let badge = tokio::time::timeout(Duration::from_secs(30), async {
9165 loop {
9166 if let Some(PrResolution::Pr(badge)) =
9167 svc.pr_cache.get("rust-works", "omni-dev", "main")
9168 {
9169 return badge;
9170 }
9171 tokio::time::sleep(Duration::from_millis(25)).await;
9172 }
9173 })
9174 .await
9175 .expect("poller should resolve a badge through the fake gh");
9176 assert_eq!(badge.number, 1337);
9177 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
9178
9179 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9181 assert_eq!(wt["pr"]["number"], json!(1337));
9182
9183 svc.shutdown().await;
9185 let generation = svc.registry.change_generation();
9186 tokio::time::sleep(Duration::from_millis(120)).await;
9187 assert_eq!(
9188 svc.registry.change_generation(),
9189 generation,
9190 "no bumps after shutdown"
9191 );
9192 }
9193
9194 #[tokio::test]
9195 #[allow(clippy::await_holding_lock)]
9197 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
9198 let dir = tempfile::tempdir().unwrap();
9202 github_repo(dir.path());
9203 let bin_dir = tempfile::tempdir().unwrap();
9204 let (fake, _shim) = fake_gh(
9205 bin_dir.path(),
9206 r#"{"data":{
9207 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
9208 "resetAt":"2026-07-21T16:00:00Z"},
9209 "r0":{"b0":{
9210 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9211 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9212 ]}}},
9213 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9214 }}
9215 }}"#,
9216 );
9217 let svc = WorktreesService::new();
9218 svc.handle(
9219 "register",
9220 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9221 )
9222 .await
9223 .unwrap();
9224 svc.registry.set_polling("rust-works", "omni-dev", true);
9225 svc.start_pr_poller_with(
9228 Duration::from_millis(50),
9229 Duration::from_millis(10),
9230 fake.clone(),
9231 );
9232
9233 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
9234 loop {
9235 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
9236 return g;
9237 }
9238 tokio::time::sleep(Duration::from_millis(25)).await;
9239 }
9240 })
9241 .await
9242 .expect("the PR poll should fold its budget into the cache");
9243 assert_eq!(graphql.used, 123);
9244 assert_eq!(graphql.limit, 5000);
9245 assert_eq!(graphql.remaining, 4877);
9246 svc.shutdown().await;
9247 }
9248
9249 #[tokio::test]
9250 #[allow(clippy::await_holding_lock)]
9252 async fn pr_poll_counts_every_gh_call_exactly_once() {
9253 let dir = tempfile::tempdir().unwrap();
9260 github_repo(dir.path());
9261 let bin_dir = tempfile::tempdir().unwrap();
9262 let (fake, _shim, counter) = counting_fake_gh(
9263 bin_dir.path(),
9264 r#"{"data":{"r0":{"b0":{
9265 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9266 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9267 ]}}},
9268 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9269 }}}}"#,
9270 );
9271 let log = bin_dir.path().join("log.jsonl");
9272 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
9273
9274 let svc = WorktreesService::new();
9275 svc.handle(
9276 "register",
9277 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9278 )
9279 .await
9280 .unwrap();
9281 svc.registry.set_polling("rust-works", "omni-dev", true);
9282 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
9283
9284 tokio::time::timeout(Duration::from_secs(30), async {
9286 loop {
9287 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
9288 return;
9289 }
9290 tokio::time::sleep(Duration::from_millis(25)).await;
9291 }
9292 })
9293 .await
9294 .expect("poller should fetch through the fake gh");
9295
9296 svc.shutdown().await;
9298 let spawns = gh_spawn_count(&counter);
9299 let counted = counted_gh_records(&log);
9300 std::env::remove_var("OMNI_DEV_LOG_FILE");
9301 assert!(
9302 spawns >= 1,
9303 "the poll should have spent at least one gh call"
9304 );
9305 assert_eq!(
9306 counted, spawns,
9307 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
9308 );
9309 }
9310
9311 #[tokio::test]
9312 #[allow(clippy::await_holding_lock)]
9314 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
9315 let dir_a = tempfile::tempdir().unwrap();
9320 let dir_b = tempfile::tempdir().unwrap();
9321 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();
9324 let (fake, _shim, counter) = counting_fake_gh(
9326 bin_dir.path(),
9327 r#"{"data":{
9328 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
9329 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9330 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
9331 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
9332 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9333 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
9334 }}"#,
9335 );
9336 let svc = WorktreesService::new();
9337 svc.registry.set_polling("rust-works", "omni-dev", true);
9340 svc.registry.set_polling("rust-works", "other-repo", true);
9341 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
9345 svc.handle(
9347 "register",
9348 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
9349 )
9350 .await
9351 .unwrap();
9352 tokio::time::sleep(Duration::from_millis(50)).await;
9356 svc.handle(
9357 "register",
9358 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
9359 )
9360 .await
9361 .unwrap();
9362
9363 tokio::time::timeout(Duration::from_secs(30), async {
9366 loop {
9367 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
9368 let b = svc
9369 .pr_cache
9370 .get("rust-works", "other-repo", "main")
9371 .is_some();
9372 if a && b {
9373 return;
9374 }
9375 tokio::time::sleep(Duration::from_millis(25)).await;
9376 }
9377 })
9378 .await
9379 .expect("the debounced fetch should resolve both repos");
9380
9381 svc.shutdown().await;
9382 assert_eq!(
9383 gh_spawn_count(&counter),
9384 1,
9385 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
9386 );
9387 }
9388
9389 #[tokio::test]
9390 #[allow(clippy::await_holding_lock)]
9392 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
9393 let dir = tempfile::tempdir().unwrap();
9398 github_repo(dir.path());
9399 let bin_dir = tempfile::tempdir().unwrap();
9400 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9401 let svc = WorktreesService::new();
9402 svc.registry.set_polling("rust-works", "omni-dev", true);
9403 svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
9407 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
9408 svc.handle("register", register.clone()).await.unwrap();
9409 let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
9419 loop {
9420 tokio::time::sleep(Duration::from_millis(25)).await;
9421 svc.handle("register", register.clone()).await.unwrap();
9422 if gh_spawn_count(&counter) >= 1 {
9423 return;
9424 }
9425 }
9426 })
9427 .await;
9428 svc.shutdown().await;
9429 forced_mid_drip.expect(
9430 "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
9431 );
9432 }
9433
9434 #[tokio::test]
9435 #[allow(clippy::await_holding_lock)]
9437 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
9438 let dir = tempfile::tempdir().unwrap();
9442 let repo = github_repo(dir.path());
9443 let head = repo.head().unwrap().target().unwrap().to_string();
9444 let bin_dir = tempfile::tempdir().unwrap();
9445 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9448
9449 let cache_path = bin_dir.path().join("pr-cache.json");
9453 let target = PrTarget {
9454 owner: "rust-works".into(),
9455 name: "omni-dev".into(),
9456 branch: "main".into(),
9457 };
9458 let prefs = pr_cache_prefs_from(
9459 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
9460 &[watch("main", None)],
9461 Utc::now(),
9462 );
9463 write_pr_cache(&cache_path, &prefs).unwrap();
9464
9465 let svc = WorktreesService::new();
9466 svc.load_pr_cache(cache_path);
9467 svc.registry.set_polling("rust-works", "omni-dev", true);
9468 svc.handle(
9469 "register",
9470 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9471 )
9472 .await
9473 .unwrap();
9474 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
9477
9478 let number = tokio::time::timeout(Duration::from_secs(30), async {
9480 loop {
9481 let tree = svc.handle("tree", Value::Null).await.unwrap();
9482 if let Some(n) = repos_of(&tree)
9483 .first()
9484 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
9485 {
9486 return n;
9487 }
9488 tokio::time::sleep(Duration::from_millis(25)).await;
9489 }
9490 })
9491 .await
9492 .expect("the restored badge should render from the warm cache");
9493 assert_eq!(number, 1337);
9494
9495 tokio::time::sleep(Duration::from_millis(300)).await;
9497 svc.shutdown().await;
9498 assert_eq!(
9499 gh_spawn_count(&counter),
9500 0,
9501 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
9502 );
9503 }
9504
9505 #[tokio::test]
9506 #[allow(clippy::await_holding_lock)]
9508 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
9509 let dir = tempfile::tempdir().unwrap();
9514 github_repo(dir.path());
9515 let bin_dir = tempfile::tempdir().unwrap();
9516 let (fake, _shim) = fake_gh(
9517 bin_dir.path(),
9518 r#"{"data":{"r0":{"b0":{
9519 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9520 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9521 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
9522 }}}}"#,
9523 );
9524 let svc = WorktreesService::new();
9525 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
9528 svc.load_pr_cache(cache_path.clone());
9529 svc.registry.set_polling("rust-works", "omni-dev", true);
9530 svc.handle(
9531 "register",
9532 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9533 )
9534 .await
9535 .unwrap();
9536 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
9537
9538 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
9541 loop {
9542 if let Ok(bytes) = std::fs::read(&cache_path) {
9543 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
9544 if !prefs.entries.is_empty() {
9545 return prefs;
9546 }
9547 }
9548 }
9549 tokio::time::sleep(Duration::from_millis(25)).await;
9550 }
9551 })
9552 .await
9553 .expect("a successful resolve should persist the cache file");
9554 svc.shutdown().await;
9555
9556 assert_eq!(prefs.entries[0].target.branch, "main");
9557 assert!(
9558 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
9559 "{:?}",
9560 prefs.entries[0].resolution
9561 );
9562 assert_eq!(
9563 prefs.watched,
9564 vec![PersistedWatch {
9565 target: prefs.entries[0].target.clone(),
9566 upstream_sha: None
9567 }]
9568 );
9569 assert!(
9570 prefs.polled_at.is_some(),
9571 "the poll time is what ages the next warm start"
9572 );
9573 }
9574
9575 #[tokio::test]
9576 #[allow(clippy::await_holding_lock)]
9578 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
9579 let bin_dir = tempfile::tempdir().unwrap();
9583 let (fake, _shim, counter) = counting_fake_gh(
9584 bin_dir.path(),
9585 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
9586 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
9587 );
9588 let svc = WorktreesService::new();
9589
9590 let prs = svc
9591 .open_prs_with("rust-works", "omni-dev", fake.clone())
9592 .await
9593 .expect("gh pr list should resolve");
9594 assert_eq!(prs.len(), 1);
9595 assert_eq!(prs[0]["number"], json!(42));
9596 assert_eq!(prs[0]["url"], json!("http://x/42"));
9597 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
9598
9599 let again = svc
9601 .open_prs_with("rust-works", "omni-dev", fake.clone())
9602 .await
9603 .expect("cache hit should resolve");
9604 assert_eq!(again, prs);
9605 assert_eq!(
9606 gh_spawn_count(&counter),
9607 1,
9608 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
9609 );
9610
9611 let reply = svc
9613 .handle(
9614 "open-prs",
9615 json!({ "owner": "rust-works", "name": "omni-dev" }),
9616 )
9617 .await
9618 .expect("open-prs op should route");
9619 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
9620 assert!(svc
9621 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
9622 .await
9623 .is_err());
9624 }
9625
9626 #[test]
9627 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
9628 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
9633 assert!(
9634 err.to_string().contains("is the GitHub CLI installed"),
9635 "{err:#}"
9636 );
9637
9638 let bin_dir = tempfile::tempdir().unwrap();
9639 let _guard = shim_lock();
9640 let failing = bin_dir.path().join("fake-gh-fails");
9641 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
9642 let err = retry_on_etxtbsy(|| open_pr_list(&failing, "rust-works/omni-dev")).unwrap_err();
9643 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
9644 assert!(err.to_string().contains("boom"), "{err:#}");
9645
9646 let object = bin_dir.path().join("fake-gh-object");
9647 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
9648 let err = retry_on_etxtbsy(|| open_pr_list(&object, "rust-works/omni-dev")).unwrap_err();
9649 assert!(
9650 err.to_string().contains("did not return a JSON array"),
9651 "{err:#}"
9652 );
9653 }
9654
9655 #[tokio::test]
9656 #[allow(clippy::await_holding_lock)]
9658 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
9659 let dir = tempfile::tempdir().unwrap();
9665 github_repo(dir.path());
9666 let bin_dir = tempfile::tempdir().unwrap();
9667 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9668
9669 let svc = WorktreesService::new();
9670 *svc.pr_warm_start
9674 .lock()
9675 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
9676 watched: vec![],
9677 polled_at: Utc::now(),
9678 });
9679 svc.rate_limit_cache.replace(RateLimitSnapshot {
9681 graphql: Some(rl_resource(90)),
9682 core: Some(rl_resource(3)),
9683 search: None,
9684 });
9685 svc.registry.set_polling("rust-works", "omni-dev", true);
9686 svc.handle(
9687 "register",
9688 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9689 )
9690 .await
9691 .unwrap();
9692 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
9695
9696 tokio::time::sleep(Duration::from_millis(300)).await;
9698 svc.shutdown().await;
9699 assert_eq!(
9700 gh_spawn_count(&counter),
9701 0,
9702 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
9703 );
9704 }
9705
9706 #[tokio::test]
9707 #[allow(clippy::await_holding_lock)]
9709 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
9710 let dir = tempfile::tempdir().unwrap();
9713 github_repo(dir.path());
9714 let bin_dir = tempfile::tempdir().unwrap();
9715 let (fake, _shim) = fake_gh(
9716 bin_dir.path(),
9717 r#"{"data":{"r0":{"b0":{
9718 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9719 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9720 ]}}},
9721 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
9722 }}}}"#,
9723 );
9724 let svc = WorktreesService::new();
9725 svc.handle(
9726 "register",
9727 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9728 )
9729 .await
9730 .unwrap();
9731 svc.registry.set_polling("rust-works", "omni-dev", true);
9734 svc.start_pr_poller_with(
9735 Duration::from_millis(50),
9736 Duration::from_millis(10),
9737 fake.clone(),
9738 );
9739
9740 tokio::time::timeout(Duration::from_secs(30), async {
9741 loop {
9742 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
9743 return;
9744 }
9745 tokio::time::sleep(Duration::from_millis(25)).await;
9746 }
9747 })
9748 .await
9749 .expect("poller should resolve a badge through the fake gh");
9750 let settled = svc.registry.change_generation();
9753 tokio::time::sleep(Duration::from_millis(150)).await;
9754 assert_eq!(
9755 svc.registry.change_generation(),
9756 settled,
9757 "an unchanged poll must not bump the change-notify"
9758 );
9759 svc.shutdown().await;
9760 }
9761
9762 #[tokio::test]
9763 #[allow(clippy::await_holding_lock)]
9765 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
9766 let dir = tempfile::tempdir().unwrap();
9771 github_repo(dir.path());
9772 let bin_dir = tempfile::tempdir().unwrap();
9773 let (fake, _shim) = fake_gh(
9774 bin_dir.path(),
9775 r#"{"data":{"r0":{"b0":{
9776 "target":{"oid":"abc","statusCheckRollup":null},
9777 "associatedPullRequests":{"nodes":[]}
9778 }}}}"#,
9779 );
9780 let svc = WorktreesService::new();
9781 svc.handle(
9782 "register",
9783 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9784 )
9785 .await
9786 .unwrap();
9787 svc.registry.set_polling("rust-works", "omni-dev", true);
9790 svc.start_pr_poller_with(
9791 Duration::from_millis(50),
9792 Duration::from_millis(10),
9793 fake.clone(),
9794 );
9795
9796 tokio::time::timeout(Duration::from_secs(30), async {
9797 loop {
9798 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
9799 return;
9800 }
9801 tokio::time::sleep(Duration::from_millis(25)).await;
9802 }
9803 })
9804 .await
9805 .expect("poller should resolve the negative through the fake gh");
9806
9807 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9809 assert_eq!(wt["pr_none"], json!(true));
9810 assert!(wt.get("pr").is_none(), "{wt:?}");
9811
9812 let settled = svc.registry.change_generation();
9814 tokio::time::sleep(Duration::from_millis(150)).await;
9815 assert_eq!(
9816 svc.registry.change_generation(),
9817 settled,
9818 "an unchanged negative must not bump the change-notify"
9819 );
9820 svc.shutdown().await;
9821 }
9822
9823 #[test]
9824 fn sync_indicator_formats_only_with_upstream() {
9825 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
9826 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
9827 assert_eq!(sync_indicator(None, None), None);
9828 assert_eq!(sync_indicator(Some(1), None), None);
9830 }
9831
9832 #[tokio::test]
9833 async fn list_enriches_entries_with_git_status() {
9834 let dir = tempfile::tempdir().unwrap();
9835 let repo = init_repo(dir.path());
9836 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9837 repo.set_head("refs/heads/main").unwrap();
9838
9839 let svc = WorktreesService::new();
9840 svc.handle(
9841 "register",
9842 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9843 )
9844 .await
9845 .unwrap();
9846 let payload = svc.handle("list", Value::Null).await.unwrap();
9847 let windows = windows_of(&payload);
9848 assert_eq!(windows.len(), 1);
9849 assert_eq!(
9850 windows[0].get("branch").and_then(Value::as_str),
9851 Some("main")
9852 );
9853 assert!(windows[0].get("ahead").is_none());
9855 assert!(windows[0].get("behind").is_none());
9856 assert_eq!(
9858 windows[0].get("main_repo").and_then(Value::as_str),
9859 dir.path().file_name().and_then(|n| n.to_str())
9860 );
9861
9862 let plain = tempfile::tempdir().unwrap();
9864 svc.handle(
9865 "register",
9866 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9867 )
9868 .await
9869 .unwrap();
9870 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9871 let w2 = windows
9872 .iter()
9873 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9874 .unwrap();
9875 assert!(w2.get("branch").is_none());
9876 assert!(w2.get("main_repo").is_none());
9877 }
9878
9879 #[test]
9880 fn window_label_prefers_git_branch_over_title() {
9881 let dir = tempfile::tempdir().unwrap();
9882 let repo = init_repo(dir.path());
9883 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9884 repo.set_head("refs/heads/main").unwrap();
9885 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9886 let entry = WindowEntry {
9887 key: "k".to_string(),
9888 folders: vec![dir.path().to_path_buf()],
9889 repo: Some("companion-repo".to_string()),
9892 title: Some("ignored title".to_string()),
9893 pid: None,
9894 last_seen: Utc::now(),
9895 last_active: Duration::ZERO,
9896 };
9897 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9899 }
9900
9901 #[tokio::test]
9902 async fn list_includes_ahead_behind_for_tracking_branch() {
9903 let dir = tempfile::tempdir().unwrap();
9904 let _repo = diverging_repo(dir.path());
9905
9906 let svc = WorktreesService::new();
9907 svc.handle(
9908 "register",
9909 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9910 )
9911 .await
9912 .unwrap();
9913 let payload = svc.handle("list", Value::Null).await.unwrap();
9914 let windows = windows_of(&payload);
9915 assert_eq!(
9917 windows[0].get("branch").and_then(Value::as_str),
9918 Some("main")
9919 );
9920 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9921 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9922 }
9923
9924 #[test]
9925 fn window_label_includes_sync_for_tracking_branch() {
9926 let dir = tempfile::tempdir().unwrap();
9927 let _repo = diverging_repo(dir.path());
9928 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9929 let entry = WindowEntry {
9930 key: "k".to_string(),
9931 folders: vec![dir.path().to_path_buf()],
9932 repo: Some("companion-repo".to_string()),
9933 title: None,
9934 pid: None,
9935 last_seen: Utc::now(),
9936 last_active: Duration::ZERO,
9937 };
9938 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9940 }
9941
9942 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9946 let commit = repo.find_commit(base).unwrap();
9947 repo.branch(branch, &commit, false).unwrap();
9948 let reference = repo
9949 .find_reference(&format!("refs/heads/{branch}"))
9950 .unwrap();
9951 let mut opts = git2::WorktreeAddOptions::new();
9952 opts.reference(Some(&reference));
9953 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9954 }
9955
9956 #[test]
9957 fn git_status_marks_linked_worktree_and_names_parent_repo() {
9958 let main_dir = tempfile::tempdir().unwrap();
9959 let repo = init_repo(main_dir.path());
9960 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9961 repo.set_head("refs/heads/main").unwrap();
9962
9963 let wt_parent = tempfile::tempdir().unwrap();
9966 let wt_path = wt_parent.path().join("feature-wt");
9967 add_worktree(&repo, a, &wt_path, "feature");
9968
9969 let status = git_status(&wt_path);
9970 assert!(status.is_worktree);
9971 assert_eq!(status.branch.as_deref(), Some("feature"));
9972 assert_eq!(
9974 status.main_repo.as_deref(),
9975 main_dir.path().file_name().and_then(|n| n.to_str())
9976 );
9977
9978 let main_status = git_status(main_dir.path());
9980 assert!(!main_status.is_worktree);
9981 assert_eq!(main_status.main_repo, status.main_repo);
9982 }
9983
9984 #[test]
9985 fn window_label_marks_worktree_with_fork_glyph() {
9986 let main_dir = tempfile::tempdir().unwrap();
9987 let repo = init_repo(main_dir.path());
9988 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9989 repo.set_head("refs/heads/main").unwrap();
9990 let wt_parent = tempfile::tempdir().unwrap();
9991 let wt_path = wt_parent.path().join("feature-wt");
9992 add_worktree(&repo, a, &wt_path, "feature");
9993
9994 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9995 let entry = WindowEntry {
9996 key: "k".to_string(),
9997 folders: vec![wt_path],
9998 repo: Some("feature-wt".to_string()),
9999 title: None,
10000 pid: None,
10001 last_seen: Utc::now(),
10002 last_active: Duration::ZERO,
10003 };
10004 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
10007 }
10008
10009 #[test]
10010 fn main_repo_name_derives_from_common_dir() {
10011 assert_eq!(
10013 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
10014 Some("omni-dev")
10015 );
10016 assert_eq!(
10018 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
10019 Some("omni-dev")
10020 );
10021 assert_eq!(
10023 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
10024 Some("omni-dev")
10025 );
10026 assert_eq!(main_repo_name(Path::new("/.git")), None);
10028 }
10029
10030 fn repos_of(payload: &Value) -> Vec<Value> {
10035 payload
10036 .get("repos")
10037 .and_then(Value::as_array)
10038 .expect("repos array")
10039 .clone()
10040 }
10041
10042 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
10043 Some(GithubIdentity {
10044 owner: owner.to_string(),
10045 name: name.to_string(),
10046 })
10047 }
10048
10049 #[test]
10050 fn github_identity_parses_supported_forms() {
10051 assert_eq!(
10053 github_identity("https://github.com/rust-works/omni-dev.git"),
10054 github("rust-works", "omni-dev")
10055 );
10056 assert_eq!(
10057 github_identity("https://github.com/rust-works/omni-dev"),
10058 github("rust-works", "omni-dev")
10059 );
10060 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
10061 assert_eq!(
10063 github_identity("git@github.com:rust-works/omni-dev.git"),
10064 github("rust-works", "omni-dev")
10065 );
10066 assert_eq!(
10067 github_identity("ssh://git@github.com/o/r.git"),
10068 github("o", "r")
10069 );
10070 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
10071 assert_eq!(
10073 github_identity(" https://github.com/o/r/ "),
10074 github("o", "r")
10075 );
10076 }
10077
10078 #[test]
10079 fn github_identity_rejects_non_github_and_malformed() {
10080 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
10082 assert_eq!(github_identity("git@example.com:o/r.git"), None);
10083 assert_eq!(github_identity("https://github.com/onlyowner"), None);
10085 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
10086 assert_eq!(github_identity("https://github.com/"), None);
10087 assert_eq!(github_identity("not a url"), None);
10089 }
10090
10091 #[test]
10092 fn remote_github_identity_reads_origin_then_falls_back() {
10093 let dir = tempfile::tempdir().unwrap();
10094 let repo = init_repo(dir.path());
10095 assert_eq!(remote_github_identity(&repo), None);
10097 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
10099 assert_eq!(remote_github_identity(&repo), None);
10100 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
10102 .unwrap();
10103 assert_eq!(
10104 remote_github_identity(&repo),
10105 github("rust-works", "omni-dev")
10106 );
10107
10108 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
10111 .unwrap();
10112 repo.remote("upstream", "https://github.com/other/proj.git")
10113 .unwrap();
10114 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
10115 }
10116
10117 #[tokio::test]
10118 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
10119 let svc = WorktreesService::new();
10120 assert_eq!(
10122 svc.handle("tree", Value::Null).await.unwrap(),
10123 json!({ "repos": [], "show_closed": true })
10124 );
10125 let plain = tempfile::tempdir().unwrap();
10127 svc.handle(
10128 "register",
10129 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
10130 )
10131 .await
10132 .unwrap();
10133 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
10134 }
10135
10136 #[tokio::test]
10137 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
10138 let main_dir = tempfile::tempdir().unwrap();
10139 let repo = init_repo(main_dir.path());
10140 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10141 repo.set_head("refs/heads/main").unwrap();
10142 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
10144 .unwrap();
10145
10146 let wt_parent = tempfile::tempdir().unwrap();
10149 let wt_path = wt_parent.path().join("feature-wt");
10150 add_worktree(&repo, a, &wt_path, "feature");
10151
10152 let svc = WorktreesService::new();
10153 svc.handle(
10156 "register",
10157 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
10158 )
10159 .await
10160 .unwrap();
10161 svc.handle(
10162 "register",
10163 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
10164 )
10165 .await
10166 .unwrap();
10167
10168 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
10169 assert_eq!(
10170 repos.len(),
10171 1,
10172 "two worktrees of one repo dedupe: {repos:?}"
10173 );
10174 let repo0 = &repos[0];
10175 assert_eq!(
10177 repo0.get("main_repo").and_then(Value::as_str),
10178 main_dir.path().file_name().and_then(|n| n.to_str())
10179 );
10180 assert_eq!(
10181 repo0.pointer("/github/owner").and_then(Value::as_str),
10182 Some("rust-works")
10183 );
10184 assert_eq!(
10185 repo0.pointer("/github/name").and_then(Value::as_str),
10186 Some("omni-dev")
10187 );
10188 assert!(repo0.get("root").and_then(Value::as_str).is_some());
10189
10190 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
10191 assert_eq!(worktrees.len(), 2);
10192 let main_wt = &worktrees[0];
10194 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
10195 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
10196 assert_eq!(
10197 main_wt.get("window_key").and_then(Value::as_str),
10198 Some("wm")
10199 );
10200 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
10201 let linked = &worktrees[1];
10203 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
10204 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
10205 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
10206 assert_eq!(
10207 linked.get("branch").and_then(Value::as_str),
10208 Some("feature")
10209 );
10210 }
10211
10212 #[tokio::test]
10213 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
10214 let main_dir = tempfile::tempdir().unwrap();
10215 let repo = init_repo(main_dir.path());
10216 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10217 repo.set_head("refs/heads/main").unwrap();
10218 let wt_parent = tempfile::tempdir().unwrap();
10220 let wt_path = wt_parent.path().join("feature-wt");
10221 add_worktree(&repo, a, &wt_path, "feature");
10222
10223 let svc = WorktreesService::new();
10224 svc.handle(
10226 "register",
10227 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
10228 )
10229 .await
10230 .unwrap();
10231
10232 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
10233 assert_eq!(repos.len(), 1);
10234 assert!(repos[0].get("github").is_none(), "no remote → no github");
10235 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
10236 let linked = worktrees
10237 .iter()
10238 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
10239 .expect("the linked worktree");
10240 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
10242 assert!(linked.get("window_key").is_none());
10243 }
10244
10245 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10251 let main_dir = tempfile::tempdir().unwrap();
10252 let repo = init_repo(main_dir.path());
10253 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10254 repo.set_head("refs/heads/trunk").unwrap();
10255 let wt_parent = tempfile::tempdir().unwrap();
10256 let wt_path = wt_parent.path().join("feature-wt");
10257 add_worktree(&repo, a, &wt_path, "feature");
10258 (main_dir, wt_parent, wt_path)
10259 }
10260
10261 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
10265 {
10266 let main_dir = tempfile::tempdir().unwrap();
10267 let repo = init_repo(main_dir.path());
10268 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10269 repo.set_head("refs/heads/trunk").unwrap();
10270 let wt_parent = tempfile::tempdir().unwrap();
10271 let first = wt_parent.path().join("first-wt");
10272 let second = wt_parent.path().join("second-wt");
10273 add_worktree(&repo, a, &first, "first");
10274 add_worktree(&repo, a, &second, "second");
10275 (main_dir, wt_parent, first, second)
10276 }
10277
10278 #[tokio::test]
10279 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
10280 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
10281 let svc = Arc::new(WorktreesService::new());
10282
10283 let close = |path: PathBuf| {
10293 let svc = svc.clone();
10294 async move {
10295 svc.handle(
10296 "close",
10297 json!({ "path": path, "remove": true, "confirmed": true }),
10298 )
10299 .await
10300 }
10301 };
10302 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
10303
10304 assert_eq!(a.unwrap(), json!({ "removed": true }));
10305 assert_eq!(b.unwrap(), json!({ "removed": true }));
10306 assert!(!first.exists());
10307 assert!(!second.exists());
10308 let repo = Repository::open(main_dir.path()).unwrap();
10311 assert!(repo.worktrees().unwrap().is_empty());
10312 }
10313
10314 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
10321 let repo = init_repo(dir);
10322 let refname = format!("refs/heads/{branch}");
10323 let head = empty_commit(&repo, Some(&refname), &[], "A");
10324 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
10325 .unwrap();
10326 repo.set_head(&refname).unwrap();
10327 let mut cfg = repo.config().unwrap();
10328 cfg.set_str("remote.origin.url", url).unwrap();
10329 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
10330 .unwrap();
10331 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
10332 .unwrap();
10333 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
10334 .unwrap();
10335 repo
10336 }
10337
10338 #[test]
10339 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
10340 let dir = tempfile::tempdir().unwrap();
10341 let _repo = pushed_github_repo(
10342 dir.path(),
10343 "https://github.com/rust-works/omni-dev.git",
10344 "feature",
10345 );
10346 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
10347 assert_eq!(
10348 ok.target,
10349 PrTarget {
10350 owner: "rust-works".into(),
10351 name: "omni-dev".into(),
10352 branch: "feature".into(),
10353 }
10354 );
10355 assert!(!ok.head_sha.is_empty());
10356 }
10357
10358 #[test]
10359 fn evaluate_local_skips_an_unborn_head() {
10360 let dir = tempfile::tempdir().unwrap();
10361 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
10363 }
10364
10365 #[test]
10366 fn evaluate_local_skips_a_branch_with_no_upstream() {
10367 let dir = tempfile::tempdir().unwrap();
10368 let repo = init_repo(dir.path());
10369 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10370 repo.set_head("refs/heads/main").unwrap();
10371 repo.config()
10373 .unwrap()
10374 .set_str("remote.origin.url", "https://github.com/o/r.git")
10375 .unwrap();
10376 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
10377 }
10378
10379 #[test]
10380 fn evaluate_local_skips_unpushed_local_commits() {
10381 let dir = tempfile::tempdir().unwrap();
10382 let repo = init_repo(dir.path());
10383 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10384 let a_commit = repo.find_commit(a).unwrap();
10385 repo.reference("refs/remotes/origin/main", a, true, "o")
10387 .unwrap();
10388 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
10389 drop(a_commit);
10390 repo.set_head("refs/heads/main").unwrap();
10391 let mut cfg = repo.config().unwrap();
10392 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
10393 .unwrap();
10394 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
10397 .unwrap();
10398 cfg.set_str("branch.main.remote", "origin").unwrap();
10399 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
10400 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
10401 }
10402
10403 #[test]
10404 fn evaluate_local_skips_a_detached_head() {
10405 let dir = tempfile::tempdir().unwrap();
10406 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
10407 let head = repo.head().unwrap().target().unwrap();
10408 repo.set_head_detached(head).unwrap();
10409 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
10410 }
10411
10412 #[test]
10413 fn evaluate_local_skips_a_non_github_remote() {
10414 let dir = tempfile::tempdir().unwrap();
10415 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
10416 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
10417 }
10418
10419 #[test]
10420 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
10421 assert_eq!(
10425 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
10426 .unwrap_err()
10427 .kind,
10428 "not-a-repo"
10429 );
10430 }
10431
10432 #[test]
10433 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
10434 let req = MergeQueueRequest {
10437 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
10438 requester_key: Some("win-9".into()),
10439 check: true,
10440 confirmed: false,
10441 };
10442 let logs = capture_info(|| log_merge_check(&req, 1, 1));
10443 assert!(logs.contains("merge-queue check"), "{logs}");
10444 assert!(logs.contains("win-9"), "{logs}");
10445 assert!(logs.contains("requested=2"), "{logs}");
10446 assert!(logs.contains("eligible=1"), "{logs}");
10447 }
10448
10449 #[test]
10450 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
10451 let req = MergeQueueRequest {
10453 paths: vec![PathBuf::from("/a")],
10454 requester_key: None,
10455 check: false,
10456 confirmed: true,
10457 };
10458 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
10459 assert!(logs.contains("merge-queue enqueue"), "{logs}");
10460 assert!(logs.contains("queued=2"), "{logs}");
10461 assert!(logs.contains("failed=1"), "{logs}");
10462 }
10463
10464 #[test]
10465 fn evaluate_local_flags_dirty_then_untracked() {
10466 let main_dir = tempfile::tempdir().unwrap();
10468 let repo = init_repo(main_dir.path());
10469 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
10470 repo.set_head("refs/heads/main").unwrap();
10471 let wt_parent = tempfile::tempdir().unwrap();
10472 let wt_path = wt_parent.path().join("feature-wt");
10473 add_worktree(&repo, a, &wt_path, "feature");
10474
10475 let clean = evaluate_local(&wt_path).unwrap_err();
10477 assert_ne!(clean.kind, "dirty");
10478 assert_ne!(clean.kind, "untracked");
10479
10480 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
10482 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
10483
10484 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
10486 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
10487 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
10488 }
10489
10490 #[test]
10491 fn is_conflicting_blocks_only_dirty_and_conflicting() {
10492 assert!(is_conflicting(Some("CONFLICTING")));
10493 assert!(is_conflicting(Some("DIRTY")));
10494 assert!(!is_conflicting(Some("CLEAN")));
10495 assert!(!is_conflicting(Some("BLOCKED")));
10496 assert!(!is_conflicting(Some("UNKNOWN")));
10497 assert!(!is_conflicting(None));
10498 }
10499
10500 #[test]
10501 fn merge_queue_request_parses_batch_and_phase_flags() {
10502 let req: MergeQueueRequest = serde_json::from_value(json!({
10503 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
10504 }))
10505 .unwrap();
10506 assert_eq!(req.paths.len(), 2);
10507 assert_eq!(req.requester_key.as_deref(), Some("w1"));
10508 assert!(req.confirmed);
10509 assert!(!req.check);
10510 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
10512 assert!(req.paths.is_empty());
10513 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
10514 }
10515
10516 #[test]
10517 fn queued_pr_omits_already_queued_when_false() {
10518 let v = serde_json::to_value(QueuedPr {
10519 path: "/a".into(),
10520 number: 5,
10521 already_queued: false,
10522 })
10523 .unwrap();
10524 assert!(v.get("already_queued").is_none(), "{v}");
10525 let v = serde_json::to_value(QueuedPr {
10526 path: "/a".into(),
10527 number: 5,
10528 already_queued: true,
10529 })
10530 .unwrap();
10531 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
10532 }
10533
10534 #[tokio::test]
10535 async fn merge_queue_check_on_empty_selection_reports_nothing() {
10536 let svc = WorktreesService::new();
10537 let reply = svc
10538 .handle("merge-queue", json!({ "paths": [], "check": true }))
10539 .await
10540 .unwrap();
10541 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
10542 }
10543
10544 #[tokio::test]
10545 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
10546 let dir = tempfile::tempdir().unwrap();
10549 let _repo = init_repo(dir.path());
10550 let svc = WorktreesService::new();
10551 let reply = svc
10552 .handle(
10553 "merge-queue",
10554 json!({ "paths": [dir.path()], "check": true }),
10555 )
10556 .await
10557 .unwrap();
10558 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
10559 assert_eq!(skipped.len(), 1);
10560 assert_eq!(
10561 skipped[0].get("kind").and_then(Value::as_str),
10562 Some("no-commits")
10563 );
10564 assert!(reply
10565 .get("eligible")
10566 .and_then(Value::as_array)
10567 .unwrap()
10568 .is_empty());
10569 }
10570
10571 fn ready_worktree() -> (tempfile::TempDir, String) {
10575 let dir = tempfile::tempdir().unwrap();
10576 let repo = pushed_github_repo(
10577 dir.path(),
10578 "https://github.com/rust-works/omni-dev.git",
10579 "feature",
10580 );
10581 let head = repo.head().unwrap().target().unwrap().to_string();
10582 (dir, head)
10583 }
10584
10585 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
10588 format!(
10589 r#"{{"data":{{"r0":{{"b0":{{
10590 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
10591 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
10592 ]}}}}}},
10593 "associatedPullRequests":{{"nodes":[{pr}]}}
10594 }}}}}}}}"#
10595 )
10596 }
10597
10598 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
10602 let ghdir = tempfile::tempdir().unwrap();
10603 let (bin, _shim) = fake_gh(ghdir.path(), reply);
10604 let paths = vec![head_dir.to_path_buf()];
10605 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
10606 if let Some(e) = eligible.first() {
10607 return Ok(e.number);
10608 }
10609 Err(skipped.remove(0).kind)
10610 }
10611
10612 #[test]
10613 fn evaluate_batch_marks_a_ready_pr_eligible() {
10614 let (dir, head) = ready_worktree();
10615 let pr = format!(
10616 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10617 );
10618 assert_eq!(
10619 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10620 Ok(9)
10621 );
10622 }
10623
10624 #[test]
10625 fn evaluate_batch_skips_a_draft_pr() {
10626 let (dir, head) = ready_worktree();
10627 let pr = format!(
10628 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10629 );
10630 assert_eq!(
10631 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10632 Err("draft".to_string())
10633 );
10634 }
10635
10636 #[test]
10637 fn evaluate_batch_skips_a_conflicting_pr() {
10638 let (dir, head) = ready_worktree();
10639 let pr = format!(
10640 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
10641 );
10642 assert_eq!(
10643 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10644 Err("conflicting".to_string())
10645 );
10646 }
10647
10648 #[test]
10649 fn evaluate_batch_skips_a_pr_with_failing_checks() {
10650 let (dir, head) = ready_worktree();
10651 let pr = format!(
10652 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10653 );
10654 assert_eq!(
10655 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
10656 Err("checks-failing".to_string())
10657 );
10658 }
10659
10660 #[test]
10661 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
10662 let (dir, head) = ready_worktree();
10663 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
10665 assert_eq!(
10666 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
10667 Err("stale".to_string())
10668 );
10669 }
10670
10671 #[test]
10672 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
10673 let (dir, head) = ready_worktree();
10674 let reply = format!(
10676 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
10677 );
10678 assert_eq!(
10679 network_gate_outcome(dir.path(), &reply),
10680 Err("no-pr".to_string())
10681 );
10682 }
10683
10684 #[test]
10685 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
10686 let eligible = vec![
10689 Eligible {
10690 path: PathBuf::from("/wt/a"),
10691 number: 1,
10692 url: "u".into(),
10693 branch: "a".into(),
10694 pr_id: "PR_A".into(),
10695 already_queued: true,
10696 },
10697 Eligible {
10698 path: PathBuf::from("/wt/b"),
10699 number: 2,
10700 url: "u".into(),
10701 branch: "b".into(),
10702 pr_id: "PR_B".into(),
10703 already_queued: false,
10704 },
10705 ];
10706 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
10707 assert_eq!(queued.len(), 1);
10708 assert_eq!(queued[0].number, 1);
10709 assert!(queued[0].already_queued);
10710 assert_eq!(failed.len(), 1);
10711 assert_eq!(failed[0].number, 2);
10712 }
10713
10714 #[test]
10715 fn enqueue_eligible_records_a_github_rejection_as_failed() {
10716 let ghdir = tempfile::tempdir().unwrap();
10720 let (bin, _shim) = fake_gh(
10721 ghdir.path(),
10722 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
10723 );
10724 let eligible = vec![Eligible {
10725 path: PathBuf::from("/wt/a"),
10726 number: 7,
10727 url: "u".into(),
10728 branch: "a".into(),
10729 pr_id: "PR_A".into(),
10730 already_queued: false,
10731 }];
10732 let (queued, failed) = enqueue_eligible(&bin, eligible);
10733 assert!(queued.is_empty(), "{queued:?}");
10734 assert_eq!(failed.len(), 1);
10735 assert_eq!(failed[0].number, 7);
10736 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
10739 }
10740
10741 #[tokio::test]
10742 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
10744 let (dir, head) = ready_worktree();
10747 let pr = format!(
10748 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10749 );
10750 let ghdir = tempfile::tempdir().unwrap();
10751 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
10752 let svc = WorktreesService::new();
10753 let req = MergeQueueRequest {
10754 paths: vec![dir.path().to_path_buf()],
10755 requester_key: None,
10756 check: true,
10757 confirmed: false,
10758 };
10759 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10763 .await
10764 .unwrap();
10765 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
10766 assert_eq!(eligible.len(), 1);
10767 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
10768 assert_eq!(
10769 eligible[0].get("branch").and_then(Value::as_str),
10770 Some("feature")
10771 );
10772 }
10773
10774 #[tokio::test]
10775 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
10777 let (dir, head) = ready_worktree();
10780 let pr = format!(
10781 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10782 );
10783 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
10784 let ghdir = tempfile::tempdir().unwrap();
10785 let guard = shim_lock();
10786 let bin = ghdir.path().join("fake-gh");
10787 write_exec_script(
10788 &bin,
10789 &format!(
10790 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
10791 enqueue =
10792 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
10793 ),
10794 );
10795 let svc = WorktreesService::new();
10796 let req = MergeQueueRequest {
10797 paths: vec![dir.path().to_path_buf()],
10798 requester_key: Some("w1".into()),
10799 check: false,
10800 confirmed: true,
10801 };
10802 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10807 .await
10808 .unwrap();
10809 drop(guard);
10810 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
10811 assert_eq!(queued.len(), 1, "{reply}");
10812 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
10813 assert!(reply
10814 .get("failed")
10815 .and_then(Value::as_array)
10816 .unwrap()
10817 .is_empty());
10818 }
10819
10820 #[tokio::test]
10821 async fn concurrent_closes_overlap_their_heartbeat_waits() {
10822 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
10823 let svc = Arc::new(WorktreesService::new());
10824 for (key, path) in [("w2", &first), ("w3", &second)] {
10826 svc.handle("register", json!({ "key": key, "folders": [path] }))
10827 .await
10828 .unwrap();
10829 }
10830
10831 let spawn_close = |path: PathBuf| {
10832 let svc = svc.clone();
10833 tokio::spawn(async move {
10834 svc.handle(
10835 "close",
10836 json!({
10837 "path": path,
10838 "remove": true,
10839 "confirmed": true,
10840 "requester_key": "w1",
10841 }),
10842 )
10843 .await
10844 })
10845 };
10846 let a = spawn_close(first.clone());
10847 let b = spawn_close(second.clone());
10848
10849 for key in ["w2", "w3"] {
10856 let mut saw_close = false;
10857 for _ in 0..400 {
10858 let hb = svc
10859 .handle("heartbeat", json!({ "key": key }))
10860 .await
10861 .unwrap();
10862 if hb.get("close").and_then(Value::as_bool) == Some(true) {
10863 saw_close = true;
10864 break;
10865 }
10866 tokio::time::sleep(Duration::from_millis(5)).await;
10867 }
10868 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10869 }
10870 assert!(
10871 !a.is_finished() && !b.is_finished(),
10872 "neither close can have finished: both windows are still registered"
10873 );
10874
10875 for key in ["w2", "w3"] {
10877 svc.handle("unregister", json!({ "key": key }))
10878 .await
10879 .unwrap();
10880 }
10881 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10882 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10883 assert!(!first.exists());
10884 assert!(!second.exists());
10885 }
10886
10887 #[tokio::test]
10888 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10889 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10890 let svc = WorktreesService::new();
10891 let report = svc
10894 .handle("close", json!({ "path": wt_path, "remove": true }))
10895 .await
10896 .unwrap();
10897 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10898 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10899 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10900 assert!(report
10901 .get("risks")
10902 .and_then(Value::as_array)
10903 .unwrap()
10904 .is_empty());
10905 assert!(wt_path.exists());
10907 }
10908
10909 #[tokio::test]
10910 async fn close_removes_a_clean_linked_worktree() {
10911 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10912 let svc = WorktreesService::new();
10913 let reply = svc
10914 .handle(
10915 "close",
10916 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10917 )
10918 .await
10919 .unwrap();
10920 assert_eq!(reply, json!({ "removed": true }));
10921 assert!(
10922 !wt_path.exists(),
10923 "the worktree directory should be deleted"
10924 );
10925 }
10926
10927 fn capture_info(f: impl FnOnce()) -> String {
10939 crate::test_support::capture_at(tracing::Level::INFO, f)
10940 }
10941
10942 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10946 RebaseRequest {
10947 paths,
10948 requester_key: None,
10949 check: false,
10950 confirmed: false,
10951 keep_conflicts: false,
10952 autostash: false,
10953 onto: None,
10954 }
10955 }
10956
10957 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10965 let main_dir = tempfile::tempdir().unwrap();
10966 let repo = init_repo(main_dir.path());
10967 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10968 repo.set_head("refs/heads/main").unwrap();
10969 let wt_parent = tempfile::tempdir().unwrap();
10970 let wt_path = wt_parent.path().join("feature-wt");
10971 add_worktree(&repo, base, &wt_path, "feature");
10972 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10974 (main_dir, wt_parent, wt_path)
10975 }
10976
10977 #[tokio::test]
10978 async fn rebase_with_refuses_an_empty_selection() {
10979 let svc = WorktreesService::new();
10981 let err = svc
10982 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10983 .await
10984 .unwrap_err()
10985 .to_string();
10986 assert!(err.contains("at least one path"), "{err}");
10987 }
10988
10989 #[tokio::test]
10990 async fn rebase_with_phase_one_reports_without_rebasing() {
10991 let (_main, _parent, wt) = behind_worktree();
10992 let before = Repository::open(&wt).unwrap().head().unwrap().target();
10993
10994 let svc = WorktreesService::new();
10995 let reply = svc
10996 .rebase_with(
10997 RebaseRequest {
10998 check: true,
10999 onto: Some("main".into()),
11000 ..rebase_req(vec![wt.clone()])
11001 },
11002 crate::git::resolve_git_binary(),
11003 )
11004 .await
11005 .unwrap();
11006
11007 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11008 assert_eq!(worktrees.len(), 1, "{reply}");
11009 assert_eq!(
11010 worktrees[0].get("status").and_then(Value::as_str),
11011 Some("would-rebase"),
11012 "{reply}"
11013 );
11014 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
11016 assert_eq!(fetches.len(), 1);
11017 assert_eq!(
11018 fetches[0].get("fetched").and_then(Value::as_bool),
11019 Some(false)
11020 );
11021 assert_eq!(
11022 Repository::open(&wt).unwrap().head().unwrap().target(),
11023 before,
11024 "phase 1 must not move the branch"
11025 );
11026 }
11027
11028 #[tokio::test]
11029 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
11030 let (_main, _parent, wt) = behind_worktree();
11031 let svc = WorktreesService::new();
11032 let reply = svc
11033 .rebase_with(
11034 RebaseRequest {
11035 confirmed: true,
11036 onto: Some("main".into()),
11037 ..rebase_req(vec![wt.clone()])
11038 },
11039 crate::git::resolve_git_binary(),
11040 )
11041 .await
11042 .unwrap();
11043
11044 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11045 assert_eq!(
11046 worktrees[0].get("status").and_then(Value::as_str),
11047 Some("rebased"),
11048 "{reply}"
11049 );
11050 assert!(
11052 svc.registry.rebasing_paths().is_empty(),
11053 "the rebasing mark must be cleared after the execute"
11054 );
11055 }
11056
11057 #[tokio::test]
11058 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
11059 let (_main, _parent, wt) = behind_worktree();
11063 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
11064
11065 let svc = WorktreesService::new();
11066 let reply = svc
11067 .rebase_with(
11068 RebaseRequest {
11069 confirmed: true,
11070 onto: Some("main".into()),
11071 ..rebase_req(vec![wt.clone()])
11072 },
11073 crate::git::resolve_git_binary(),
11074 )
11075 .await
11076 .unwrap();
11077
11078 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11079 assert_eq!(
11080 worktrees[0].get("status").and_then(Value::as_str),
11081 Some("skipped"),
11082 "{reply}"
11083 );
11084 assert_eq!(
11085 worktrees[0].get("reason").and_then(Value::as_str),
11086 Some("dirty"),
11087 "{reply}"
11088 );
11089 }
11090
11091 #[tokio::test]
11092 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
11093 let (_main, _parent, wt) = behind_worktree();
11099 std::fs::create_dir_all(wt.join(".git")).ok();
11102 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
11103 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
11104 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
11105 assert_ne!(
11106 Repository::open(&wt).unwrap().state(),
11107 RepositoryState::Clean,
11108 "precondition: the worktree looks mid-rebase to git2"
11109 );
11110
11111 let svc = WorktreesService::new();
11112 let reply = svc
11113 .rebase_with(
11114 RebaseRequest {
11115 confirmed: true,
11116 onto: Some("main".into()),
11117 ..rebase_req(vec![wt.clone()])
11118 },
11119 crate::git::resolve_git_binary(),
11120 )
11121 .await
11122 .unwrap();
11123
11124 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11125 assert_eq!(
11126 worktrees[0].get("reason").and_then(Value::as_str),
11127 Some("operation-in-progress"),
11128 "{reply}"
11129 );
11130 assert_ne!(
11132 Repository::open(&wt).unwrap().state(),
11133 RepositoryState::Clean
11134 );
11135 }
11136
11137 #[test]
11138 fn rebase_request_maps_onto_engine_options() {
11139 let req = RebaseRequest {
11140 keep_conflicts: true,
11141 autostash: true,
11142 onto: Some("origin/release".into()),
11143 ..rebase_req(vec![PathBuf::from("/wt")])
11144 };
11145 let opts = req.options(PathBuf::from("/custom/git"));
11146 assert!(opts.keep_conflicts && opts.autostash);
11147 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
11148 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
11149 assert!(!opts.dry_run);
11153 }
11154
11155 #[test]
11156 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
11157 let req = RebaseRequest {
11158 requester_key: Some("win-3".into()),
11159 check: true,
11160 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
11161 };
11162 let plan = worktree_rebase::Plan {
11163 fetches: vec![worktree_rebase::FetchOutcome {
11164 repo_root: PathBuf::from("/repo"),
11165 onto: "origin/main".into(),
11166 fetched: true,
11167 ok: false,
11168 detail: Some("host unreachable".into()),
11169 }],
11170 worktrees: vec![
11171 worktree_rebase::WorktreeOutcome {
11172 path: PathBuf::from("/a"),
11173 branch: Some("a".into()),
11174 onto: "origin/main".into(),
11175 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
11176 },
11177 worktree_rebase::WorktreeOutcome {
11178 path: PathBuf::from("/b"),
11179 branch: Some("b".into()),
11180 onto: "origin/main".into(),
11181 result: worktree_rebase::RebaseResult::UpToDate,
11182 },
11183 ],
11184 };
11185 let logs = capture_info(|| log_rebase_check(&req, &plan));
11186 assert!(logs.contains("rebase check"), "{logs}");
11187 assert!(logs.contains("win-3"), "{logs}");
11188 assert!(logs.contains("requested=2"), "{logs}");
11189 assert!(logs.contains("pending=1"), "{logs}");
11190 assert!(logs.contains("failed_fetches=1"), "{logs}");
11191 }
11192
11193 #[test]
11194 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
11195 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
11197 let outcome = |result| worktree_rebase::WorktreeOutcome {
11198 path: PathBuf::from("/x"),
11199 branch: Some("x".into()),
11200 onto: "origin/main".into(),
11201 result,
11202 };
11203 let outcomes = vec![
11204 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
11205 outcome(worktree_rebase::RebaseResult::Conflict {
11206 detail: "CONFLICT".into(),
11207 left_in_place: true,
11208 }),
11209 outcome(worktree_rebase::RebaseResult::Skipped {
11210 reason: worktree_rebase::SkipReason::Dirty,
11211 }),
11212 ];
11213 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
11214 assert!(logs.contains("rebase execute"), "{logs}");
11215 assert!(logs.contains("rebased=1"), "{logs}");
11216 assert!(logs.contains("conflicts=1"), "{logs}");
11217 assert!(logs.contains("left_in_place=1"), "{logs}");
11218 assert!(logs.contains("skipped=1"), "{logs}");
11219 assert!(logs.contains(r#"requester="-""#), "{logs}");
11220 }
11221
11222 fn push_req(paths: Vec<PathBuf>) -> PushRequest {
11226 PushRequest {
11227 paths,
11228 requester_key: None,
11229 check: false,
11230 confirmed: false,
11231 }
11232 }
11233
11234 fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
11241 let _guard = crate::git::worktree_batch::test_serial_lock();
11244 let root = tempfile::tempdir().unwrap();
11245 let origin = root.path().join("origin.git");
11246 let local = root.path().join("local");
11247 let wt = root.path().join("feature-wt");
11248 std::fs::create_dir_all(&origin).unwrap();
11249 std::fs::create_dir_all(&local).unwrap();
11250
11251 let git = |dir: &Path, args: &[&str]| {
11252 let out = crate::git::worktree_batch::run_git_in(
11253 &crate::git::resolve_git_binary(),
11254 dir,
11255 args,
11256 )
11257 .unwrap();
11258 assert!(
11259 out.status.success(),
11260 "git {args:?} failed: {}",
11261 String::from_utf8_lossy(&out.stderr)
11262 );
11263 };
11264
11265 git(&origin, &["init", "--bare", "-b", "main"]);
11266 git(&local, &["init", "-b", "main"]);
11267 git(&local, &["config", "user.name", "Test"]);
11268 git(&local, &["config", "user.email", "test@example.com"]);
11269 git(&local, &["config", "commit.gpgsign", "false"]);
11270 std::fs::write(local.join("f.txt"), "base\n").unwrap();
11271 git(&local, &["add", "f.txt"]);
11272 git(&local, &["commit", "-m", "base"]);
11273 git(
11274 &local,
11275 &["remote", "add", "origin", origin.to_str().unwrap()],
11276 );
11277 git(&local, &["push", "-u", "origin", "main"]);
11278 git(
11279 &local,
11280 &[
11281 "worktree",
11282 "add",
11283 "-b",
11284 "feature",
11285 wt.to_str().unwrap(),
11286 "main",
11287 ],
11288 );
11289 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
11290 git(&wt, &["add", "g.txt"]);
11291 git(&wt, &["commit", "-m", "work"]);
11292 git(&wt, &["push", "-u", "origin", "feature"]);
11293 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
11295
11296 (root, origin, std::fs::canonicalize(&wt).unwrap())
11297 }
11298
11299 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
11301 Repository::open_bare(origin)
11302 .unwrap()
11303 .refname_to_id(refname)
11304 .ok()
11305 }
11306
11307 #[tokio::test]
11308 async fn push_with_refuses_an_empty_selection() {
11309 let svc = WorktreesService::new();
11311 let err = svc
11312 .push_with(push_req(Vec::new()), PathBuf::from("git"))
11313 .await
11314 .unwrap_err()
11315 .to_string();
11316 assert!(err.contains("at least one path"), "{err}");
11317 }
11318
11319 #[tokio::test]
11320 async fn push_with_phase_one_reports_without_publishing() {
11321 let (_root, origin, wt) = rewritten_worktree();
11322 let before = origin_tip(&origin, "refs/heads/feature");
11323
11324 let svc = WorktreesService::new();
11325 let reply = svc
11326 .push_with(
11327 PushRequest {
11328 check: true,
11329 ..push_req(vec![wt.clone()])
11330 },
11331 crate::git::resolve_git_binary(),
11332 )
11333 .await
11334 .unwrap();
11335
11336 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11337 assert_eq!(worktrees.len(), 1, "{reply}");
11338 assert_eq!(
11339 worktrees[0].get("status").and_then(Value::as_str),
11340 Some("would-force"),
11341 "{reply}"
11342 );
11343 assert!(
11344 reply.get("fetches").is_none(),
11345 "a push plan contacts no remote, so it reports no fetches: {reply}"
11346 );
11347 assert_eq!(
11348 origin_tip(&origin, "refs/heads/feature"),
11349 before,
11350 "phase 1 must publish nothing"
11351 );
11352 assert!(
11353 svc.registry.pushing_paths().is_empty(),
11354 "phase 1 must not mark a row as in flight"
11355 );
11356 }
11357
11358 #[tokio::test]
11359 async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
11360 let (_root, origin, wt) = rewritten_worktree();
11361 let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
11362
11363 let svc = WorktreesService::new();
11364 let reply = svc
11365 .push_with(
11366 PushRequest {
11367 confirmed: true,
11368 ..push_req(vec![wt.clone()])
11369 },
11370 crate::git::resolve_git_binary(),
11371 )
11372 .await
11373 .unwrap();
11374
11375 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11376 assert_eq!(
11377 worktrees[0].get("status").and_then(Value::as_str),
11378 Some("pushed"),
11379 "{reply}"
11380 );
11381 assert_eq!(
11382 worktrees[0].get("forced").and_then(Value::as_bool),
11383 Some(true),
11384 "a rewritten branch is published under the lease: {reply}"
11385 );
11386 assert_eq!(
11387 origin_tip(&origin, "refs/heads/feature"),
11388 rewritten,
11389 "the remote must carry the rewritten tip"
11390 );
11391 assert!(
11392 svc.registry.pushing_paths().is_empty(),
11393 "the pushing mark must be cleared after the execute — a push writes no \
11394 on-disk state, so nothing else could ever correct a leftover"
11395 );
11396 }
11397
11398 #[tokio::test]
11399 async fn push_resolves_the_git_binary_for_itself() {
11400 let (_root, origin, wt) = rewritten_worktree();
11404 let before = origin_tip(&origin, "refs/heads/feature");
11405
11406 let svc = WorktreesService::new();
11407 let reply = svc
11408 .push(PushRequest {
11409 check: true,
11410 ..push_req(vec![wt])
11411 })
11412 .await
11413 .unwrap();
11414
11415 assert_eq!(
11416 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
11417 .get("status")
11418 .and_then(Value::as_str),
11419 Some("would-force"),
11420 "{reply}"
11421 );
11422 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
11423 }
11424
11425 #[tokio::test]
11426 async fn push_with_defaults_to_report_only_without_confirmation() {
11427 let (_root, origin, wt) = rewritten_worktree();
11430 let before = origin_tip(&origin, "refs/heads/feature");
11431
11432 let svc = WorktreesService::new();
11433 let reply = svc
11434 .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
11435 .await
11436 .unwrap();
11437
11438 assert_eq!(
11439 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
11440 .get("status")
11441 .and_then(Value::as_str),
11442 Some("would-force"),
11443 "{reply}"
11444 );
11445 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
11446 }
11447
11448 #[tokio::test]
11449 async fn push_with_refuses_to_force_the_remote_default_branch() {
11450 let (root, origin, _wt) = rewritten_worktree();
11453 let local = root.path().join("local");
11454 let before = origin_tip(&origin, "refs/heads/main");
11455 crate::git::worktree_batch::run_git_in(
11456 &crate::git::resolve_git_binary(),
11457 &local,
11458 &["commit", "--amend", "-m", "rewritten main"],
11459 )
11460 .unwrap();
11461
11462 let svc = WorktreesService::new();
11463 let reply = svc
11464 .push_with(
11465 PushRequest {
11466 confirmed: true,
11467 ..push_req(vec![local.clone()])
11468 },
11469 crate::git::resolve_git_binary(),
11470 )
11471 .await
11472 .unwrap();
11473
11474 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11475 assert_eq!(
11476 worktrees[0].get("reason").and_then(Value::as_str),
11477 Some("default-branch-force-push"),
11478 "{reply}"
11479 );
11480 assert_eq!(
11481 origin_tip(&origin, "refs/heads/main"),
11482 before,
11483 "the default branch's published history must be untouched"
11484 );
11485 }
11486
11487 #[test]
11488 fn log_push_check_separates_the_force_count_from_the_pending_count() {
11489 let req = PushRequest {
11490 requester_key: Some("win-7".into()),
11491 check: true,
11492 ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
11493 };
11494 let outcome = |result| worktree_push::WorktreeOutcome {
11495 path: PathBuf::from("/x"),
11496 branch: Some("x".into()),
11497 remote: "origin".into(),
11498 remote_branch: "x".into(),
11499 result,
11500 };
11501 let plan = worktree_push::Plan {
11502 worktrees: vec![
11503 outcome(worktree_push::PushResult::WouldForce {
11504 ahead: 1,
11505 behind: 1,
11506 }),
11507 outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
11508 outcome(worktree_push::PushResult::Skipped {
11509 reason: worktree_push::SkipReason::DefaultBranchForcePush,
11510 }),
11511 ],
11512 };
11513 let logs = capture_info(|| log_push_check(&req, &plan));
11514 assert!(logs.contains("push check"), "{logs}");
11515 assert!(logs.contains("pending=2"), "{logs}");
11516 assert!(logs.contains("forced=1"), "{logs}");
11517 assert!(logs.contains("skipped=1"), "{logs}");
11518 assert!(logs.contains(r#"requester="win-7""#), "{logs}");
11519 }
11520
11521 #[test]
11522 fn log_push_execute_counts_lease_refusals_separately() {
11523 let req = push_req(vec![PathBuf::from("/a")]);
11524 let outcome = |result| worktree_push::WorktreeOutcome {
11525 path: PathBuf::from("/x"),
11526 branch: Some("x".into()),
11527 remote: "origin".into(),
11528 remote_branch: "x".into(),
11529 result,
11530 };
11531 let outcomes = vec![
11532 outcome(worktree_push::PushResult::Pushed { forced: true }),
11533 outcome(worktree_push::PushResult::Pushed { forced: false }),
11534 outcome(worktree_push::PushResult::Created),
11535 outcome(worktree_push::PushResult::Rejected {
11536 detail: "stale info".into(),
11537 stale: true,
11538 }),
11539 outcome(worktree_push::PushResult::Rejected {
11540 detail: "pre-receive hook declined".into(),
11541 stale: false,
11542 }),
11543 ];
11544 let logs = capture_info(|| log_push_execute(&req, &outcomes));
11545 assert!(logs.contains("push execute"), "{logs}");
11546 assert!(logs.contains("pushed=2"), "{logs}");
11547 assert!(logs.contains("forced=1"), "{logs}");
11548 assert!(logs.contains("created=1"), "{logs}");
11549 assert!(logs.contains("rejected=2"), "{logs}");
11550 assert!(
11551 logs.contains("stale_rejected=1"),
11552 "a lease refusal is the interesting half of a rejection: {logs}"
11553 );
11554 }
11555
11556 #[test]
11557 fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
11558 let dir = tempfile::tempdir().unwrap();
11559 let path = canonical(dir.path());
11560
11561 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
11562 assert!(!quiet.pushing);
11563 let json = serde_json::to_value(&quiet).unwrap();
11564 assert!(
11565 json.get("pushing").is_none(),
11566 "an idle row stays byte-identical for an older client: {json}"
11567 );
11568
11569 let busy = worktree_entry(
11570 &path,
11571 true,
11572 &HashMap::new(),
11573 &InFlight {
11574 pushing: [path.clone()].into(),
11575 rebasing: HashSet::new(),
11576 },
11577 );
11578 assert!(busy.pushing, "the registry's transient mark rides through");
11579 assert!(
11580 !busy.rebasing,
11581 "the two cues are independent — a push must not read as a rebase"
11582 );
11583 assert_eq!(
11584 serde_json::to_value(&busy).unwrap()["pushing"],
11585 serde_json::json!(true)
11586 );
11587 }
11588
11589 #[test]
11590 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
11591 assert_eq!(operation_slug(RepositoryState::Clean), None);
11592 assert_eq!(
11593 operation_slug(RepositoryState::Rebase).as_deref(),
11594 Some("rebase")
11595 );
11596 assert_eq!(
11597 operation_slug(RepositoryState::RebaseMerge).as_deref(),
11598 Some("rebase"),
11599 "the merge-backend rebase is still just a rebase to the user"
11600 );
11601 assert_eq!(
11602 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
11603 Some("rebase-interactive")
11604 );
11605 assert_eq!(
11606 operation_slug(RepositoryState::Merge).as_deref(),
11607 Some("merge")
11608 );
11609 assert_eq!(
11610 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
11611 Some("cherry-pick")
11612 );
11613 assert_eq!(
11614 operation_slug(RepositoryState::RevertSequence).as_deref(),
11615 Some("revert")
11616 );
11617 assert_eq!(
11618 operation_slug(RepositoryState::Bisect).as_deref(),
11619 Some("bisect")
11620 );
11621 assert_eq!(
11622 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
11623 Some("apply-mailbox")
11624 );
11625 }
11626
11627 #[test]
11628 fn git_status_omits_operation_for_a_clean_worktree() {
11629 let dir = tempfile::tempdir().unwrap();
11630 let _repo = diverging_repo(dir.path());
11631 assert_eq!(
11632 git_status(dir.path()).operation,
11633 None,
11634 "a clean worktree carries no operation, so the field stays off the wire"
11635 );
11636 }
11637
11638 #[test]
11639 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
11640 let main_dir = tempfile::tempdir().unwrap();
11641 let repo = init_repo(main_dir.path());
11642 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
11643 repo.set_head("refs/heads/main").unwrap();
11644 let path = canonical(main_dir.path());
11645
11646 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
11647 assert!(!quiet.rebasing);
11648 let json = serde_json::to_value(&quiet).unwrap();
11650 assert!(json.get("rebasing").is_none(), "{json}");
11651 assert!(json.get("operation").is_none(), "{json}");
11652
11653 let busy = worktree_entry(
11654 &path,
11655 true,
11656 &HashMap::new(),
11657 &InFlight {
11658 rebasing: std::iter::once(path.clone()).collect(),
11659 pushing: HashSet::new(),
11660 },
11661 );
11662 assert!(busy.rebasing, "the registry's transient mark rides through");
11663 assert_eq!(
11664 serde_json::to_value(&busy).unwrap()["rebasing"],
11665 serde_json::Value::Bool(true)
11666 );
11667 }
11668
11669 #[test]
11670 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
11671 assert_eq!(note_kinds(&[]), "-");
11672 assert_eq!(
11673 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
11674 "dirty,untracked"
11675 );
11676 }
11677
11678 #[test]
11679 fn is_self_close_true_only_when_requester_owns_an_open_window() {
11680 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
11681 assert!(is_self_close(Some("w1"), &windows));
11682 assert!(
11683 !is_self_close(Some("w3"), &windows),
11684 "requester owns no window"
11685 );
11686 assert!(!is_self_close(None, &windows), "no requester");
11687 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
11688 }
11689
11690 #[test]
11691 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
11692 let logs = capture_info(|| {
11693 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
11694 assert_eq!(reply, json!({ "removed": true }));
11695 });
11696 assert!(
11697 logs.contains("worktrees close: linked worktree pruned"),
11698 "a successful prune must log an INFO audit line, got: {logs}"
11699 );
11700 assert!(
11701 logs.contains("/wt/feature"),
11702 "the target path must ride the line, got: {logs}"
11703 );
11704 }
11705
11706 #[test]
11707 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
11708 let logs = capture_info(|| {
11713 let reply =
11714 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
11715 assert_eq!(reply, json!({ "removed": true }));
11716 });
11717 assert!(
11718 logs.contains("worktrees close: nothing to prune, worktree already removed"),
11719 "an already-gone close must log its own outcome, got: {logs}"
11720 );
11721 assert!(
11722 !logs.contains("linked worktree pruned"),
11723 "an already-gone close must not claim it pruned, got: {logs}"
11724 );
11725 }
11726
11727 #[test]
11728 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
11729 let logs = capture_info(|| {
11731 let err = log_close_error(
11732 Path::new("/wt/feature"),
11733 "safety check",
11734 anyhow!("not a git worktree"),
11735 );
11736 assert_eq!(
11737 err.to_string(),
11738 "not a git worktree",
11739 "err propagates unchanged"
11740 );
11741 });
11742 assert!(
11743 logs.contains("worktrees close: safety check failed"),
11744 "a failed phase must log an ERROR audit line, got: {logs}"
11745 );
11746 assert!(
11747 logs.contains("not a git worktree"),
11748 "the cause must ride the line, got: {logs}"
11749 );
11750 assert!(
11751 logs.contains("/wt/feature"),
11752 "the target path must ride the line, got: {logs}"
11753 );
11754 }
11755
11756 #[test]
11757 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
11758 let logs = capture_info(|| {
11759 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
11760 assert!(err.is_err(), "a prune failure must propagate");
11761 });
11762 assert!(
11763 logs.contains("worktrees close: worktree prune failed"),
11764 "a prune failure must log a WARN audit line, got: {logs}"
11765 );
11766 assert!(
11767 logs.contains("locked"),
11768 "the failure cause must ride the line, got: {logs}"
11769 );
11770 }
11771
11772 #[test]
11773 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
11774 let git = GitSafety {
11775 is_main: false,
11776 removable: true,
11777 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
11778 info: vec![],
11779 };
11780 let logs = capture_info(|| {
11781 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
11782 });
11783 assert!(
11784 logs.contains("worktrees close: safety check"),
11785 "phase-1 must log a safety-check line, got: {logs}"
11786 );
11787 assert!(
11788 logs.contains("/wt/feature"),
11789 "the path must ride the line, got: {logs}"
11790 );
11791 assert!(
11792 logs.contains("window_key=\"win-42\""),
11793 "the owning window key must ride the line, got: {logs}"
11794 );
11795 assert!(logs.contains("removable=true"), "got: {logs}");
11796 assert!(logs.contains("is_main=false"), "got: {logs}");
11797 assert!(logs.contains("open=true"), "got: {logs}");
11798 assert!(
11799 logs.contains("risks=dirty,untracked"),
11800 "the blocking risk kinds must ride the line, got: {logs}"
11801 );
11802 }
11803
11804 #[test]
11805 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
11806 let git = GitSafety {
11807 is_main: false,
11808 removable: true,
11809 risks: vec![],
11810 info: vec![],
11811 };
11812 let logs = capture_info(|| {
11813 log_safety_check(Path::new("/wt/feature"), None, &git, false);
11814 });
11815 assert!(
11816 logs.contains("window_key=\"-\""),
11817 "no owning window → dash, got: {logs}"
11818 );
11819 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11820 }
11821
11822 #[test]
11823 fn log_executing_logs_the_routing_decision() {
11824 let logs = capture_info(|| {
11825 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11826 });
11827 assert!(
11828 logs.contains("worktrees close: executing"),
11829 "phase-2 must log the execute routing, got: {logs}"
11830 );
11831 assert!(
11832 logs.contains("requester=\"win-7\""),
11833 "the requester key must ride the line, got: {logs}"
11834 );
11835 assert!(logs.contains("remove=true"), "got: {logs}");
11836 assert!(logs.contains("self_close=false"), "got: {logs}");
11837 assert!(logs.contains("cross_window=3"), "got: {logs}");
11838 }
11839
11840 #[test]
11841 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11842 let logs = capture_info(|| {
11843 log_close_abort(
11844 Path::new("/wt/feature"),
11845 &anyhow!("window(s) did not close in time: win-9"),
11846 );
11847 });
11848 assert!(
11849 logs.contains("worktrees close: aborted"),
11850 "an abort must log a WARN audit line, got: {logs}"
11851 );
11852 assert!(
11853 logs.contains("/wt/feature"),
11854 "the path must ride the line, got: {logs}"
11855 );
11856 assert!(
11857 logs.contains("win-9"),
11858 "the still-open window must ride the line, got: {logs}"
11859 );
11860 }
11861
11862 #[test]
11863 fn log_window_closed_logs_the_no_removal_outcome() {
11864 let logs = capture_info(|| {
11865 log_window_closed(Path::new("/wt/feature"));
11866 });
11867 assert!(
11868 logs.contains("worktrees close: window closed, no removal"),
11869 "a remove:false close must log the no-removal outcome, got: {logs}"
11870 );
11871 assert!(
11872 logs.contains("/wt/feature"),
11873 "the path must ride the line, got: {logs}"
11874 );
11875 }
11876
11877 #[test]
11878 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11879 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11883 let admin = main.path().join(".git").join("worktrees").join("feature");
11884 assert!(admin.exists(), "admin metadata should exist before removal");
11885
11886 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11887
11888 assert!(!wt_path.exists(), "the working directory should be gone");
11889 assert!(!admin.exists(), "the admin metadata should be pruned");
11890 let main_repo = Repository::open(main.path()).unwrap();
11891 assert_eq!(
11892 main_repo.worktrees().unwrap().len(),
11893 0,
11894 "git should no longer track the worktree"
11895 );
11896 }
11897
11898 #[test]
11899 fn remove_worktree_recovers_a_half_removed_orphan() {
11900 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11905 let admin = main.path().join(".git").join("worktrees").join("feature");
11906 std::fs::remove_dir_all(&admin).unwrap();
11908 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11909 assert!(
11910 Repository::open(&wt_path).is_err(),
11911 "the orphan should not open as a repo"
11912 );
11913
11914 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11915 assert!(
11916 !wt_path.exists(),
11917 "the leftover directory should be removed"
11918 );
11919 }
11920
11921 fn orphaned_admin_worktree() -> (
11929 tempfile::TempDir,
11930 PathBuf,
11931 tempfile::TempDir,
11932 PathBuf,
11933 PathBuf,
11934 ) {
11935 let main_dir = tempfile::tempdir().unwrap();
11936 let main_root = main_dir.path().canonicalize().unwrap();
11937 let repo = init_repo(&main_root);
11938 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11939 repo.set_head("refs/heads/trunk").unwrap();
11940 let wt_parent = tempfile::tempdir().unwrap();
11941 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11942 add_worktree(&repo, a, &wt_path, "feature");
11943 let admin = main_root.join(".git").join("worktrees").join("feature");
11944 assert!(admin.exists(), "admin metadata exists before the orphaning");
11945 std::fs::remove_dir_all(&wt_path).unwrap();
11947 (main_dir, main_root, wt_parent, wt_path, admin)
11948 }
11949
11950 fn window_on(folder: &Path) -> WindowEntry {
11951 WindowEntry {
11952 key: "w".to_string(),
11953 folders: vec![folder.to_path_buf()],
11954 repo: None,
11955 title: None,
11956 pid: None,
11957 last_seen: Utc::now(),
11958 last_active: Duration::ZERO,
11959 }
11960 }
11961
11962 #[test]
11963 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11964 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11968
11969 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11970
11971 assert_eq!(
11972 removed,
11973 Removal::Pruned,
11974 "the orphaned admin must be pruned"
11975 );
11976 assert!(!admin.exists(), "the admin metadata should be gone");
11977 let main_repo = Repository::open(&main_root).unwrap();
11978 assert!(
11979 main_repo.worktrees().unwrap().is_empty(),
11980 "git should no longer track the orphaned worktree"
11981 );
11982 }
11983
11984 #[test]
11985 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11986 let main_dir = tempfile::tempdir().unwrap();
11990 let main_root = main_dir.path().canonicalize().unwrap();
11991 let repo = init_repo(&main_root);
11992 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11993 repo.set_head("refs/heads/trunk").unwrap();
11994 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11996 let wt_path = main_root.join(".nested").join("feature-wt");
11997 add_worktree(&repo, a, &wt_path, "feature");
11998 let admin = main_root.join(".git").join("worktrees").join("feature");
11999 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
12000
12001 let removed = remove_worktree(&wt_path, &[]).unwrap();
12002
12003 assert_eq!(removed, Removal::Pruned);
12004 assert!(!admin.exists(), "the admin metadata should be gone");
12005 }
12006
12007 #[test]
12008 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
12009 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12013 std::fs::remove_dir_all(&admin).unwrap();
12015
12016 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
12017
12018 assert_eq!(removed, Removal::AlreadyGone);
12019 }
12020
12021 #[test]
12022 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
12023 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
12024 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
12027 assert!(
12028 roots.contains(&main_root),
12029 "the owning main repo must be a candidate, got: {roots:?}"
12030 );
12031 }
12032
12033 #[test]
12034 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
12035 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12039 let junk = tempfile::tempdir().unwrap();
12040
12041 let removed =
12042 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
12043
12044 assert_eq!(removed, Removal::Pruned);
12045 assert!(
12046 !admin.exists(),
12047 "the real owner must still prune the orphan"
12048 );
12049 }
12050
12051 #[test]
12052 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
12053 let main_dir = tempfile::tempdir().unwrap();
12057 let main_root = main_dir.path().canonicalize().unwrap();
12058 let repo = init_repo(&main_root);
12059 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12060 repo.set_head("refs/heads/trunk").unwrap();
12061 let wt_parent = tempfile::tempdir().unwrap();
12062 let wt_root = wt_parent.path().canonicalize().unwrap();
12063 let orphan = wt_root.join("orphan-wt");
12064 let sibling = wt_root.join("sibling-wt");
12065 add_worktree(&repo, a, &orphan, "orphan");
12066 add_worktree(&repo, a, &sibling, "sibling");
12067 let admin = main_root.join(".git").join("worktrees").join("orphan");
12068 std::fs::remove_dir_all(&orphan).unwrap();
12069
12070 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
12073
12074 assert_eq!(removed, Removal::Pruned);
12075 assert!(
12076 !admin.exists(),
12077 "the orphan's admin metadata must be pruned"
12078 );
12079 }
12080
12081 #[test]
12082 fn prune_orphaned_admin_refuses_a_locked_orphan() {
12083 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12087 let main_repo = Repository::open(&main_root).unwrap();
12088 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
12089 main_repo
12090 .find_worktree(&name)
12091 .unwrap()
12092 .lock(Some("in use"))
12093 .unwrap();
12094
12095 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
12096
12097 assert!(
12098 err.to_string().contains("locked"),
12099 "a locked orphan must be refused, got: {err:#}"
12100 );
12101 assert!(admin.exists(), "a refused prune must leave the admin entry");
12102 }
12103
12104 #[test]
12105 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
12106 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12107 assert!(!is_orphaned_worktree(&wt_path));
12109 assert!(!is_orphaned_worktree(main.path()));
12111 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
12113 .unwrap();
12114 assert!(is_orphaned_worktree(&wt_path));
12115 }
12116
12117 #[test]
12118 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
12119 let tmp = tempfile::tempdir().unwrap();
12120 let missing = tmp.path().join("gone");
12121 assert!(remove_dir_all_retrying(&missing).is_ok());
12122 }
12123
12124 #[test]
12125 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
12126 use std::io::Error;
12127 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
12128 assert!(
12129 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
12130 "errno {errno} is the concurrent-writer race and must be retried"
12131 );
12132 }
12133 for errno in [
12136 nix::libc::EACCES,
12137 nix::libc::EPERM,
12138 nix::libc::EROFS,
12139 nix::libc::ENOTDIR,
12140 ] {
12141 assert!(
12142 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
12143 "errno {errno} is permanent and must not be retried"
12144 );
12145 }
12146 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
12148 }
12149
12150 #[test]
12151 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
12152 let tmp = tempfile::tempdir().unwrap();
12156 let file = tmp.path().join("not-a-directory");
12157 std::fs::write(&file, b"x").unwrap();
12158
12159 let mut attempts = 0;
12160 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
12161 attempts += 1;
12162 std::fs::remove_dir_all(&file)
12163 })
12164 .unwrap_err();
12165
12166 assert_eq!(attempts, 1, "a permanent error must not be retried");
12167 assert!(
12168 err.to_string()
12169 .contains("failed to remove worktree directory"),
12170 "unexpected error: {err:#}"
12171 );
12172 assert!(err.source().is_some(), "the io::Error cause is preserved");
12173 assert!(file.exists());
12174 }
12175
12176 #[test]
12177 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
12178 let tmp = tempfile::tempdir().unwrap();
12182 let mut attempts = 0;
12183 let backoff = [Duration::ZERO, Duration::ZERO];
12184 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
12185 attempts += 1;
12186 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
12187 })
12188 .unwrap_err();
12189
12190 assert_eq!(attempts, backoff.len() + 1);
12192 assert!(
12193 err.to_string()
12194 .contains("failed to remove worktree directory"),
12195 "unexpected error: {err:#}"
12196 );
12197 }
12198
12199 #[test]
12200 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
12201 let tmp = tempfile::tempdir().unwrap();
12204 let mut attempts = 0;
12205 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
12206 attempts += 1;
12207 if attempts < 3 {
12208 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
12209 } else {
12210 Ok(())
12211 }
12212 });
12213 assert!(result.is_ok(), "{result:?}");
12214 assert_eq!(attempts, 3);
12215 }
12216
12217 #[test]
12218 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
12219 let tmp = tempfile::tempdir().unwrap();
12222 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
12223 assert!(!is_orphaned_worktree(tmp.path()));
12224 }
12225
12226 #[test]
12227 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
12228 let tmp = tempfile::tempdir().unwrap();
12231 let plain = tmp.path().join("plain");
12232 std::fs::create_dir(&plain).unwrap();
12233
12234 let err = remove_worktree(&plain, &[]).unwrap_err();
12235
12236 assert!(
12237 err.to_string().contains("not a git worktree"),
12238 "unexpected error: {err:#}"
12239 );
12240 assert!(plain.exists(), "a non-worktree path must be left alone");
12241 }
12242
12243 #[test]
12244 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
12245 use std::sync::atomic::{AtomicBool, Ordering};
12251 use std::sync::Arc;
12252
12253 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12254 let nested = wt_path.join("target").join("nested");
12260 std::fs::create_dir_all(&nested).unwrap();
12261
12262 let stop = Arc::new(AtomicBool::new(false));
12263 let writer_stop = Arc::clone(&stop);
12264 let writer_dir = nested;
12265 let writer = std::thread::spawn(move || {
12266 let mut n = 0u64;
12267 let deadline = std::time::Instant::now() + Duration::from_millis(400);
12270 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
12271 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
12276 n += 1;
12277 }
12278 });
12279
12280 let result = remove_worktree(&wt_path, &[]);
12281 stop.store(true, Ordering::Relaxed);
12282 writer.join().unwrap();
12283
12284 assert!(
12285 result.is_ok(),
12286 "removal should retry past the writer: {result:?}"
12287 );
12288 assert!(!wt_path.exists(), "the worktree directory should be gone");
12289 }
12290
12291 #[tokio::test]
12292 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
12293 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12294 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
12296
12297 let svc = WorktreesService::new();
12298 let report = svc
12299 .handle("close", json!({ "path": wt_path, "remove": true }))
12300 .await
12301 .unwrap();
12302 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12303 assert!(
12304 risks
12305 .iter()
12306 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
12307 "expected an untracked risk: {report}"
12308 );
12309 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12311 assert!(wt_path.exists());
12313 }
12314
12315 #[tokio::test]
12316 async fn close_confirmed_removes_a_dirty_worktree() {
12317 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12318 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
12319 let svc = WorktreesService::new();
12320 let reply = svc
12322 .handle(
12323 "close",
12324 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12325 )
12326 .await
12327 .unwrap();
12328 assert_eq!(reply, json!({ "removed": true }));
12329 assert!(!wt_path.exists());
12330 }
12331
12332 #[tokio::test]
12333 async fn close_refuses_to_remove_the_main_working_tree() {
12334 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12335 let svc = WorktreesService::new();
12336 let report = svc
12338 .handle("close", json!({ "path": main.path(), "remove": true }))
12339 .await
12340 .unwrap();
12341 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
12342 assert_eq!(
12343 report.get("removable").and_then(Value::as_bool),
12344 Some(false)
12345 );
12346 assert!(svc
12349 .handle(
12350 "close",
12351 json!({ "path": main.path(), "remove": true, "confirmed": true }),
12352 )
12353 .await
12354 .is_err());
12355 assert!(main.path().exists());
12356 }
12357
12358 #[tokio::test]
12359 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
12360 let main_dir = tempfile::tempdir().unwrap();
12364 let repo = init_repo(main_dir.path());
12365 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12366 repo.set_head("refs/heads/trunk").unwrap();
12367 let wt_parent = tempfile::tempdir().unwrap();
12368 let wt_path = wt_parent.path().join("main-wt");
12369 add_worktree(&repo, a, &wt_path, "main");
12370
12371 let svc = WorktreesService::new();
12372 let reply = svc
12373 .handle(
12374 "close",
12375 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12376 )
12377 .await
12378 .unwrap();
12379 assert_eq!(reply, json!({ "removed": true }));
12380 assert!(!wt_path.exists());
12381 assert!(
12383 repo.find_branch("main", git2::BranchType::Local).is_ok(),
12384 "the default branch must survive worktree removal"
12385 );
12386 }
12387
12388 #[tokio::test]
12389 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
12390 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12391 let svc = WorktreesService::new();
12392 svc.handle(
12394 "close",
12395 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12396 )
12397 .await
12398 .unwrap();
12399 let reply = svc
12402 .handle(
12403 "close",
12404 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12405 )
12406 .await
12407 .unwrap();
12408 assert_eq!(reply, json!({ "removed": true }));
12409 }
12410
12411 #[tokio::test]
12412 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
12413 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12418 let svc = WorktreesService::new();
12419 svc.handle(
12422 "register",
12423 register_payload("main-w", None, &main_root.display().to_string()),
12424 )
12425 .await
12426 .unwrap();
12427
12428 let before = svc.handle("tree", Value::Null).await.unwrap();
12430 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
12431 assert_eq!(
12432 worktrees_before, 2,
12433 "the orphaned row is present before close"
12434 );
12435
12436 let reply = svc
12437 .handle(
12438 "close",
12439 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12440 )
12441 .await
12442 .unwrap();
12443 assert_eq!(reply, json!({ "removed": true }));
12444
12445 assert!(!admin.exists(), "the admin metadata must be pruned");
12447 let after = svc.handle("tree", Value::Null).await.unwrap();
12448 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
12449 assert_eq!(worktrees_after, 1, "only the main working tree remains");
12450 }
12451
12452 #[tokio::test]
12453 async fn close_safety_check_detects_detached_head_unreachable_commits() {
12454 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12455 let wt_repo = Repository::open(&wt_path).unwrap();
12458 let parent_oid = wt_repo.head().unwrap().target().unwrap();
12459 let parent = wt_repo.find_commit(parent_oid).unwrap();
12460 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
12461 wt_repo.set_head_detached(orphan).unwrap();
12462
12463 let svc = WorktreesService::new();
12464 let report = svc
12465 .handle("close", json!({ "path": wt_path, "remove": true }))
12466 .await
12467 .unwrap();
12468 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12469 assert!(
12470 risks
12471 .iter()
12472 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12473 "expected an unreachable-commits risk: {report}"
12474 );
12475 }
12476
12477 #[tokio::test]
12478 async fn close_self_close_removes_when_the_requester_owns_the_target() {
12479 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12480 let svc = WorktreesService::new();
12481 svc.handle(
12485 "register",
12486 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
12487 )
12488 .await
12489 .unwrap();
12490 let reply = svc
12491 .handle(
12492 "close",
12493 json!({
12494 "path": wt_path,
12495 "remove": true,
12496 "confirmed": true,
12497 "requester_key": "w1",
12498 }),
12499 )
12500 .await
12501 .unwrap();
12502 assert_eq!(reply, json!({ "removed": true }));
12503 assert!(!wt_path.exists());
12504 }
12505
12506 #[tokio::test]
12507 async fn close_safety_check_surfaces_the_owning_window() {
12508 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12509 let svc = WorktreesService::new();
12510 svc.handle(
12513 "register",
12514 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
12515 )
12516 .await
12517 .unwrap();
12518 let report = svc
12519 .handle("close", json!({ "path": wt_path, "remove": true }))
12520 .await
12521 .unwrap();
12522 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
12523 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
12524 assert_eq!(
12525 report.get("window_folder_count").and_then(Value::as_u64),
12526 Some(2)
12527 );
12528 }
12529
12530 #[tokio::test]
12531 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
12532 let svc = WorktreesService::new();
12533 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12534 .await
12535 .unwrap();
12536 assert_eq!(
12538 svc.handle("heartbeat", json!({ "key": "w1" }))
12539 .await
12540 .unwrap(),
12541 json!({ "known": true })
12542 );
12543 svc.registry.mark_close_pending("w1");
12545 assert_eq!(
12546 svc.handle("heartbeat", json!({ "key": "w1" }))
12547 .await
12548 .unwrap(),
12549 json!({ "known": true, "close": true })
12550 );
12551 assert_eq!(
12552 svc.handle("heartbeat", json!({ "key": "w1" }))
12553 .await
12554 .unwrap(),
12555 json!({ "known": true })
12556 );
12557 }
12558
12559 #[tokio::test]
12562 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
12563 let svc = WorktreesService::new();
12564 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12565 .await
12566 .unwrap();
12567 assert_eq!(
12570 svc.handle("heartbeat", json!({ "key": "w1" }))
12571 .await
12572 .unwrap(),
12573 json!({ "known": true })
12574 );
12575 svc.registry.mark_reload_pending("w1");
12577 assert_eq!(
12578 svc.handle("heartbeat", json!({ "key": "w1" }))
12579 .await
12580 .unwrap(),
12581 json!({ "known": true, "reload": true })
12582 );
12583 assert_eq!(
12584 svc.handle("heartbeat", json!({ "key": "w1" }))
12585 .await
12586 .unwrap(),
12587 json!({ "known": true })
12588 );
12589 }
12590
12591 #[tokio::test]
12592 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
12593 let svc = WorktreesService::new();
12594 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12595 .await
12596 .unwrap();
12597 svc.registry.mark_close_pending("w1");
12601 svc.registry.mark_reload_pending("w1");
12602 assert_eq!(
12603 svc.handle("heartbeat", json!({ "key": "w1" }))
12604 .await
12605 .unwrap(),
12606 json!({ "known": true, "close": true, "reload": true })
12607 );
12608 assert_eq!(
12609 svc.handle("heartbeat", json!({ "key": "w1" }))
12610 .await
12611 .unwrap(),
12612 json!({ "known": true })
12613 );
12614 }
12615
12616 #[tokio::test]
12617 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
12618 let svc = WorktreesService::new();
12619 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12620 .await
12621 .unwrap();
12622 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
12623 .await
12624 .unwrap();
12625
12626 let reply = svc
12629 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
12630 .await
12631 .unwrap();
12632 assert_eq!(
12633 reply,
12634 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
12635 );
12636
12637 assert!(svc.registry.take_reload_pending("w1"));
12640 assert!(svc.registry.take_reload_pending("w2"));
12641 assert!(!svc.registry.take_reload_pending("ghost"));
12642 }
12643
12644 #[tokio::test]
12645 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
12646 let svc = WorktreesService::new();
12647 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12648 .await
12649 .unwrap();
12650
12651 assert_eq!(
12654 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
12655 .await
12656 .unwrap(),
12657 json!({ "requested": 1, "signalled": 1, "unknown": [] })
12658 );
12659
12660 assert_eq!(
12663 svc.handle("reload", json!({ "target_keys": [] }))
12664 .await
12665 .unwrap(),
12666 json!({ "requested": 0, "signalled": 0, "unknown": [] })
12667 );
12668 assert_eq!(
12669 svc.handle("reload", json!({})).await.unwrap(),
12670 json!({ "requested": 0, "signalled": 0, "unknown": [] })
12671 );
12672 }
12673
12674 #[tokio::test]
12675 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
12676 let svc = WorktreesService::new();
12677 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12678 .await
12679 .unwrap();
12680 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
12681 .await
12682 .unwrap();
12683
12684 svc.handle("reload", json!({ "target_keys": ["w2"] }))
12687 .await
12688 .unwrap();
12689 assert_eq!(
12690 svc.handle("heartbeat", json!({ "key": "w2" }))
12691 .await
12692 .unwrap(),
12693 json!({ "known": true, "reload": true })
12694 );
12695 assert_eq!(
12697 svc.handle("heartbeat", json!({ "key": "w1" }))
12698 .await
12699 .unwrap(),
12700 json!({ "known": true })
12701 );
12702 }
12703
12704 #[tokio::test]
12705 async fn reload_op_treats_an_unregistered_window_as_unknown() {
12706 let svc = WorktreesService::new();
12707 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12708 .await
12709 .unwrap();
12710 svc.handle("unregister", json!({ "key": "w1" }))
12711 .await
12712 .unwrap();
12713 assert_eq!(
12716 svc.handle("reload", json!({ "target_keys": ["w1"] }))
12717 .await
12718 .unwrap(),
12719 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
12720 );
12721 }
12722
12723 #[tokio::test]
12724 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
12725 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12726 let svc = Arc::new(WorktreesService::new());
12727 svc.handle(
12729 "register",
12730 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12731 )
12732 .await
12733 .unwrap();
12734
12735 let svc2 = svc.clone();
12738 let path = wt_path.clone();
12739 let close = tokio::spawn(async move {
12740 svc2.handle(
12741 "close",
12742 json!({
12743 "path": path,
12744 "remove": true,
12745 "confirmed": true,
12746 "requester_key": "w1",
12747 }),
12748 )
12749 .await
12750 });
12751
12752 let mut saw_close = false;
12755 for _ in 0..200 {
12756 let hb = svc
12757 .handle("heartbeat", json!({ "key": "w2" }))
12758 .await
12759 .unwrap();
12760 if hb.get("close").and_then(Value::as_bool) == Some(true) {
12761 saw_close = true;
12762 svc.handle("unregister", json!({ "key": "w2" }))
12763 .await
12764 .unwrap();
12765 break;
12766 }
12767 tokio::time::sleep(Duration::from_millis(5)).await;
12768 }
12769 assert!(saw_close, "w2 should have been told to close");
12770
12771 let reply = close.await.unwrap().unwrap();
12773 assert_eq!(reply, json!({ "removed": true }));
12774 assert!(!wt_path.exists());
12775 }
12776
12777 #[tokio::test]
12778 async fn await_windows_closed_times_out_when_a_window_never_closes() {
12779 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12780 let svc = WorktreesService::new();
12781 svc.handle(
12782 "register",
12783 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12784 )
12785 .await
12786 .unwrap();
12787 let err = await_windows_closed(
12790 &svc.registry,
12791 &wt_path,
12792 Some("w1"),
12793 Duration::from_millis(150),
12794 Duration::from_millis(25),
12795 )
12796 .await
12797 .unwrap_err();
12798 assert!(
12799 err.to_string().contains("w2"),
12800 "error names the window: {err}"
12801 );
12802 await_windows_closed(
12804 &svc.registry,
12805 &wt_path,
12806 Some("w2"),
12807 Duration::from_millis(150),
12808 Duration::from_millis(25),
12809 )
12810 .await
12811 .unwrap();
12812 }
12813
12814 async fn service_with_window(key: &str, folder: &Path) -> (WorktreesService, Vec<String>) {
12819 let svc = WorktreesService::new();
12820 svc.handle("register", json!({ "key": key, "folders": [folder] }))
12821 .await
12822 .unwrap();
12823 (svc, vec![key.to_string()])
12824 }
12825
12826 async fn heartbeat_reply(svc: &WorktreesService, key: &str) -> Value {
12828 svc.handle("heartbeat", json!({ "key": key }))
12829 .await
12830 .unwrap()
12831 }
12832
12833 #[tokio::test]
12834 async fn a_timed_out_close_leaves_no_directive_for_the_slow_window() {
12835 let dir = tempfile::tempdir().unwrap();
12836 let (svc, others) = service_with_window("w2", dir.path()).await;
12837
12838 let err = svc
12840 .signal_and_await_close(
12841 dir.path(),
12842 Some("w1"),
12843 &others,
12844 Duration::from_millis(150),
12845 Duration::from_millis(25),
12846 )
12847 .await
12848 .unwrap_err();
12849 assert!(err.to_string().contains("w2"), "names the window: {err}");
12850
12851 assert_eq!(
12854 heartbeat_reply(&svc, "w2").await,
12855 json!({ "known": true }),
12856 "a failed close must have no later side effect"
12857 );
12858 }
12859
12860 #[tokio::test]
12861 async fn a_failed_close_leaves_a_reload_directive_alone() {
12862 let dir = tempfile::tempdir().unwrap();
12863 let (svc, others) = service_with_window("w2", dir.path()).await;
12864 svc.registry.mark_reload_pending("w2");
12866
12867 svc.signal_and_await_close(
12868 dir.path(),
12869 Some("w1"),
12870 &others,
12871 Duration::from_millis(100),
12872 Duration::from_millis(25),
12873 )
12874 .await
12875 .unwrap_err();
12876
12877 assert_eq!(
12878 heartbeat_reply(&svc, "w2").await,
12879 json!({ "known": true, "reload": true }),
12880 "only the close directive is withdrawn"
12881 );
12882 }
12883
12884 #[tokio::test]
12885 async fn a_close_whose_window_is_reaped_still_closes_it_if_it_returns() {
12886 let dir = tempfile::tempdir().unwrap();
12887 let (svc, others) = service_with_window("w2", dir.path()).await;
12888 svc.registry.advance_clock(Duration::from_secs(3600));
12893
12894 svc.signal_and_await_close(
12895 dir.path(),
12896 Some("w1"),
12897 &others,
12898 Duration::from_secs(5),
12899 Duration::from_millis(25),
12900 )
12901 .await
12902 .unwrap();
12903
12904 assert_eq!(
12905 heartbeat_reply(&svc, "w2").await,
12906 json!({ "known": false, "close": true }),
12907 "it is told to close on the heartbeat that answers `known: false`"
12908 );
12909 }
12910
12911 #[tokio::test]
12912 async fn a_failed_close_also_withdraws_the_directive_of_a_window_reaped_meanwhile() {
12913 let dir = tempfile::tempdir().unwrap();
12914 let (svc, _) = service_with_window("w2", dir.path()).await;
12915 svc.registry.advance_clock(Duration::from_secs(3600));
12919 svc.handle("register", json!({ "key": "w3", "folders": [dir.path()] }))
12920 .await
12921 .unwrap();
12922
12923 let err = svc
12924 .signal_and_await_close(
12925 dir.path(),
12926 Some("w1"),
12927 &["w2".to_string(), "w3".to_string()],
12928 Duration::from_millis(100),
12929 Duration::from_millis(25),
12930 )
12931 .await
12932 .unwrap_err();
12933 assert!(
12934 err.to_string().contains("w3"),
12935 "names the live window: {err}"
12936 );
12937
12938 assert_eq!(heartbeat_reply(&svc, "w3").await, json!({ "known": true }));
12939 assert_eq!(heartbeat_reply(&svc, "w2").await, json!({ "known": false }));
12940 }
12941
12942 #[tokio::test]
12943 async fn a_successful_close_still_signals_the_window_once() {
12944 let dir = tempfile::tempdir().unwrap();
12945 let svc = Arc::new(WorktreesService::new());
12946 svc.handle("register", json!({ "key": "w2", "folders": [dir.path()] }))
12947 .await
12948 .unwrap();
12949
12950 let path = dir.path().to_path_buf();
12951 let waiter = {
12952 let svc = svc.clone();
12953 tokio::spawn(async move {
12954 svc.signal_and_await_close(
12955 &path,
12956 Some("w1"),
12957 &["w2".to_string()],
12958 Duration::from_secs(10),
12959 Duration::from_millis(10),
12960 )
12961 .await
12962 })
12963 };
12964
12965 let mut saw_close = false;
12968 for _ in 0..400 {
12969 if heartbeat_reply(&svc, "w2").await.get("close") == Some(&Value::Bool(true)) {
12970 saw_close = true;
12971 break;
12972 }
12973 tokio::time::sleep(Duration::from_millis(5)).await;
12974 }
12975 assert!(saw_close, "w2 should have been told to close");
12976 svc.handle("unregister", json!({ "key": "w2" }))
12977 .await
12978 .unwrap();
12979
12980 waiter.await.unwrap().unwrap();
12981 svc.handle("register", json!({ "key": "w2", "folders": [dir.path()] }))
12983 .await
12984 .unwrap();
12985 assert_eq!(heartbeat_reply(&svc, "w2").await, json!({ "known": true }));
12986 }
12987
12988 #[tokio::test]
12989 async fn close_window_without_remove_replies_closed_and_never_deletes() {
12990 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12991 let svc = WorktreesService::new();
12992 let reply = svc
12994 .handle("close", json!({ "path": main.path(), "remove": false }))
12995 .await
12996 .unwrap();
12997 assert_eq!(reply, json!({ "closed": true }));
12998 assert!(main.path().exists());
12999 }
13000
13001 #[tokio::test]
13002 async fn close_safety_check_flags_modified_tracked_files() {
13003 let main_dir = tempfile::tempdir().unwrap();
13007 let repo = init_repo(main_dir.path());
13008 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
13009 repo.set_head("refs/heads/trunk").unwrap();
13010 let wt_parent = tempfile::tempdir().unwrap();
13011 let wt_path = wt_parent.path().join("feature-wt");
13012 add_worktree(&repo, a, &wt_path, "feature");
13013 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
13014
13015 let svc = WorktreesService::new();
13016 let report = svc
13017 .handle("close", json!({ "path": wt_path, "remove": true }))
13018 .await
13019 .unwrap();
13020 let risks = report.get("risks").and_then(Value::as_array).unwrap();
13021 assert!(
13022 risks
13023 .iter()
13024 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
13025 "expected a dirty risk: {report}"
13026 );
13027 }
13028
13029 #[tokio::test]
13030 async fn close_safety_check_flags_an_in_progress_operation() {
13031 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13034 let wt_repo = Repository::open(&wt_path).unwrap();
13035 let head = wt_repo.head().unwrap().target().unwrap();
13036 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
13037 assert_ne!(wt_repo.state(), RepositoryState::Clean);
13038
13039 let svc = WorktreesService::new();
13040 let report = svc
13041 .handle("close", json!({ "path": wt_path, "remove": true }))
13042 .await
13043 .unwrap();
13044 let risks = report.get("risks").and_then(Value::as_array).unwrap();
13045 assert!(
13046 risks
13047 .iter()
13048 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
13049 "expected an in-progress risk: {report}"
13050 );
13051 }
13052
13053 #[tokio::test]
13054 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
13055 let main_dir = tempfile::tempdir().unwrap();
13059 let repo = init_repo(main_dir.path());
13060 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
13061 repo.set_head("refs/heads/trunk").unwrap();
13062 let a_commit = repo.find_commit(a).unwrap();
13063 repo.branch("feature", &a_commit, false).unwrap();
13064 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
13065 .unwrap();
13066 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
13068 drop(a_commit);
13069 let mut cfg = repo.config().unwrap();
13070 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
13071 .unwrap();
13072 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
13073 .unwrap();
13074 cfg.set_str("branch.feature.remote", "origin").unwrap();
13075 cfg.set_str("branch.feature.merge", "refs/heads/feature")
13076 .unwrap();
13077 let wt_parent = tempfile::tempdir().unwrap();
13080 let wt_path = wt_parent.path().join("feature-wt");
13081 let reference = repo.find_reference("refs/heads/feature").unwrap();
13082 let mut opts = git2::WorktreeAddOptions::new();
13083 opts.reference(Some(&reference));
13084 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
13085
13086 let svc = WorktreesService::new();
13087 let report = svc
13088 .handle("close", json!({ "path": wt_path, "remove": true }))
13089 .await
13090 .unwrap();
13091 let info = report.get("info").and_then(Value::as_array).unwrap();
13094 assert!(
13095 info.iter()
13096 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
13097 "expected an unpushed info note: {report}"
13098 );
13099 assert!(
13100 report
13101 .get("risks")
13102 .and_then(Value::as_array)
13103 .unwrap()
13104 .is_empty(),
13105 "unpushed commits alone must not block: {report}"
13106 );
13107 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13108 }
13109
13110 #[tokio::test]
13111 async fn close_safety_check_ignores_gitignored_files() {
13112 let main_dir = tempfile::tempdir().unwrap();
13116 let repo = init_repo(main_dir.path());
13117 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
13118 repo.set_head("refs/heads/trunk").unwrap();
13119 let wt_parent = tempfile::tempdir().unwrap();
13120 let wt_path = wt_parent.path().join("feature-wt");
13121 add_worktree(&repo, a, &wt_path, "feature");
13122 std::fs::create_dir(wt_path.join("build")).unwrap();
13123 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
13124
13125 let svc = WorktreesService::new();
13126 let report = svc
13127 .handle("close", json!({ "path": wt_path, "remove": true }))
13128 .await
13129 .unwrap();
13130 assert!(
13131 report
13132 .get("risks")
13133 .and_then(Value::as_array)
13134 .unwrap()
13135 .is_empty(),
13136 "a gitignored file must not create a risk: {report}"
13137 );
13138 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13139 }
13140
13141 #[tokio::test]
13142 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
13143 let svc = WorktreesService::new();
13146 let report = svc
13147 .handle(
13148 "close",
13149 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
13150 )
13151 .await
13152 .unwrap();
13153 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13154 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
13155 assert!(report
13156 .get("risks")
13157 .and_then(Value::as_array)
13158 .unwrap()
13159 .is_empty());
13160 let info = report.get("info").and_then(Value::as_array).unwrap();
13161 assert!(info
13162 .iter()
13163 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
13164 }
13165
13166 #[tokio::test]
13167 async fn close_phase1_errors_on_a_non_git_worktree_path() {
13168 let dir = tempfile::tempdir().unwrap();
13173 let svc = WorktreesService::new();
13174 let result = svc
13175 .handle("close", json!({ "path": dir.path(), "remove": true }))
13176 .await;
13177 assert!(
13178 result.is_err(),
13179 "a non-git-worktree target must error the safety check, got: {result:?}"
13180 );
13181 }
13182
13183 #[tokio::test]
13184 async fn close_refuses_a_locked_worktree() {
13185 let (main, _wtp, wt_path) = repo_with_linked_worktree();
13188 let main_repo = Repository::open(main.path()).unwrap();
13189 main_repo
13190 .find_worktree("feature")
13191 .unwrap()
13192 .lock(Some("under test"))
13193 .unwrap();
13194
13195 let svc = WorktreesService::new();
13196 let err = svc
13197 .handle(
13198 "close",
13199 json!({ "path": wt_path, "remove": true, "confirmed": true }),
13200 )
13201 .await
13202 .unwrap_err();
13203 assert!(
13204 err.to_string().contains("locked"),
13205 "expected a locked error: {err}"
13206 );
13207 assert!(wt_path.exists(), "a locked worktree must not be removed");
13208 }
13209
13210 #[tokio::test]
13211 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
13212 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13216 let wt_repo = Repository::open(&wt_path).unwrap();
13217 let tip = wt_repo.head().unwrap().target().unwrap();
13220 wt_repo.set_head_detached(tip).unwrap();
13221 assert!(wt_repo.head_detached().unwrap());
13222
13223 let svc = WorktreesService::new();
13224 let report = svc
13225 .handle("close", json!({ "path": wt_path, "remove": true }))
13226 .await
13227 .unwrap();
13228 let risks = report.get("risks").and_then(Value::as_array).unwrap();
13229 assert!(
13230 !risks
13231 .iter()
13232 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
13233 "a detached HEAD reachable from a branch must not be flagged: {report}"
13234 );
13235 }
13236
13237 #[test]
13238 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
13239 let (main, _wtp, wt_path) = repo_with_linked_worktree();
13240 let main_repo = Repository::open(main.path()).unwrap();
13241 assert_eq!(
13243 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
13244 "feature"
13245 );
13246 let err =
13249 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
13250 assert!(
13251 err.to_string().contains("not registered"),
13252 "expected a not-registered error: {err}"
13253 );
13254 }
13255
13256 #[test]
13257 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
13258 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13261 let repo = Repository::open(&wt_path).unwrap();
13262 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
13263 assert!(
13266 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
13267 "a corrupt index should make statuses() fail"
13268 );
13269 assert_eq!(count_dirty_untracked(&repo), (0, 0));
13270 }
13271}