1mod geometry;
34
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::process::{Command, Stdio};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::{Arc, Mutex, PoisonError};
40use std::time::{Duration, Instant};
41
42use anyhow::{anyhow, bail, Context, Result};
43use chrono::{DateTime, Utc};
44
45use crate::git::remote::RemoteInfo;
46use crate::git::worktree_batch::Selection;
47use crate::git::worktree_push;
48use crate::git::worktree_rebase;
49use crate::github_rate_limit::{
50 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
51};
52use crate::pr_status::{
53 EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
54};
55use async_trait::async_trait;
56use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
57use serde::{Deserialize, Serialize};
58use serde_json::{json, Value};
59use tokio::sync::watch;
60use tokio::sync::Mutex as AsyncMutex;
61use tokio::task::JoinHandle;
62use tokio_util::sync::CancellationToken;
63
64use crate::daemon::service::{
65 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
66};
67use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
68
69pub const SERVICE_NAME: &str = "worktrees";
71
72const SUBMENU_TITLE: &str = "Worktrees";
74
75const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
78
79const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
82
83const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
95
96fn menu_refresh_interval() -> Duration {
99 crate::daemon::server::duration_secs_from_env(
100 ENV_MENU_REFRESH_INTERVAL,
101 DEFAULT_MENU_REFRESH_INTERVAL,
102 )
103}
104
105const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
109
110const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
118
119const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
127
128const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
134
135const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
141
142const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
148
149const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
153
154const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
161
162fn pr_poll_interval() -> Duration {
165 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
166}
167
168fn pr_debounce_interval() -> Duration {
171 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
172}
173
174const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
178
179const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
184
185const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
189
190const OPEN_PR_LIST_LIMIT: &str = "100";
193
194fn open_pr_ttl() -> Duration {
197 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
198}
199
200const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
204
205const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
212
213fn rate_limit_poll_interval() -> Duration {
216 crate::daemon::server::duration_secs_from_env(
217 ENV_RATE_LIMIT_POLL_INTERVAL,
218 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
219 )
220}
221
222struct RefreshTask {
224 token: CancellationToken,
226 handle: JoinHandle<()>,
228}
229
230fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
247 grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
248}
249
250fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
265 next.iter().any(|w| !prev.contains(w))
266}
267
268fn next_pr_poll_delay(
286 current: Duration,
287 base: Duration,
288 pending: bool,
289 since_moved: Option<Duration>,
290) -> Duration {
291 if !pending {
292 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
293 }
294 match since_moved {
295 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
297 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
300 }
301}
302
303fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
315 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
316 delay.max(BUDGET_THROTTLE_INTERVAL)
317 } else {
318 delay
319 }
320}
321
322fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
330 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
331 [
335 (prev.and_then(|p| p.graphql), next.graphql),
336 (prev.and_then(|p| p.core), next.core),
337 (prev.and_then(|p| p.search), next.search),
338 ]
339 .into_iter()
340 .any(|(before, after)| over(after) && !over(before))
341}
342
343struct PollerTask {
345 token: CancellationToken,
347 handle: JoinHandle<()>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
368struct PrWatch {
369 target: PrTarget,
371 upstream_sha: Option<String>,
373}
374
375fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
384 let mut out = Vec::new();
385 for repo in snapshot
386 .get("repos")
387 .and_then(Value::as_array)
388 .into_iter()
389 .flatten()
390 {
391 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
396 continue;
397 }
398 let Some(github) = repo.get("github") else {
399 continue;
400 };
401 let (Some(owner), Some(name)) = (
402 github.get("owner").and_then(Value::as_str),
403 github.get("name").and_then(Value::as_str),
404 ) else {
405 continue;
406 };
407 for wt in repo
408 .get("worktrees")
409 .and_then(Value::as_array)
410 .into_iter()
411 .flatten()
412 {
413 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
414 out.push(PrWatch {
415 upstream_sha: wt
416 .get("upstream_sha")
417 .and_then(Value::as_str)
418 .map(str::to_string),
419 target: PrTarget {
420 owner: owner.to_string(),
421 name: name.to_string(),
422 branch: branch.to_string(),
423 },
424 });
425 }
426 }
427 }
428 out.sort();
429 out.dedup();
430 out
431}
432
433#[cfg(test)]
436fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
437 pr_watch_from_snapshot(snapshot)
438 .into_iter()
439 .map(|w| w.target)
440 .collect()
441}
442
443pub struct WorktreesService {
445 registry: Arc<WorktreesRegistry>,
448 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
454 refresh: Mutex<Option<RefreshTask>>,
456 pr_cache: Arc<PrStatusCache>,
462 poller: Mutex<Option<PollerTask>>,
465 rate_limit_cache: Arc<RateLimitCache>,
473 rate_limit_poller: Mutex<Option<PollerTask>>,
476 tree_cache: Arc<TreeSnapshotCache>,
482 prune_lock: tokio::sync::Mutex<()>,
499 polling_prefs_path: Mutex<Option<PathBuf>>,
507 pr_cache_path: Mutex<Option<PathBuf>>,
514 pr_warm_start: Mutex<Option<PrWarmStart>>,
519 open_pr_cache: Arc<OpenPrCache>,
524 reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
540 rebase_lock: tokio::sync::Mutex<()>,
552 push_lock: tokio::sync::Mutex<()>,
569}
570
571impl WorktreesService {
572 #[must_use]
577 pub fn new() -> Self {
578 let registry = Arc::new(WorktreesRegistry::new());
579 let pr_cache = Arc::new(PrStatusCache::new());
580 Self {
581 registry: registry.clone(),
582 menu_cache: Arc::new(Mutex::new(None)),
583 refresh: Mutex::new(None),
584 pr_cache: pr_cache.clone(),
585 poller: Mutex::new(None),
586 rate_limit_cache: Arc::new(RateLimitCache::new()),
587 rate_limit_poller: Mutex::new(None),
588 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
589 prune_lock: tokio::sync::Mutex::new(()),
590 polling_prefs_path: Mutex::new(None),
591 pr_cache_path: Mutex::new(None),
592 pr_warm_start: Mutex::new(None),
593 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
594 reposition_undo: Mutex::new(Vec::new()),
595 rebase_lock: tokio::sync::Mutex::new(()),
596 push_lock: tokio::sync::Mutex::new(()),
597 }
598 }
599
600 pub fn load_polling_prefs(&self, path: PathBuf) {
609 match std::fs::read(&path) {
610 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
611 Ok(prefs) => self
612 .registry
613 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
614 Err(err) => tracing::warn!(
615 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
616 path.display()
617 ),
618 },
619 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
620 Err(err) => tracing::warn!(
621 "could not read worktrees polling prefs at {}: {err:#}",
622 path.display()
623 ),
624 }
625 *self
626 .polling_prefs_path
627 .lock()
628 .unwrap_or_else(PoisonError::into_inner) = Some(path);
629 }
630
631 fn persist_polling_prefs(&self) {
638 let Some(path) = self
639 .polling_prefs_path
640 .lock()
641 .unwrap_or_else(PoisonError::into_inner)
642 .clone()
643 else {
644 return;
645 };
646 let prefs = PollingPrefs {
647 enabled: self
648 .registry
649 .polling_snapshot()
650 .into_iter()
651 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
652 .collect(),
653 };
654 if let Err(err) = write_polling_prefs(&path, &prefs) {
655 tracing::warn!(
656 "could not persist worktrees polling prefs to {}: {err:#}",
657 path.display()
658 );
659 }
660 }
661
662 pub fn load_pr_cache(&self, path: PathBuf) {
675 match std::fs::read(&path) {
676 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
677 Ok(prefs) => {
678 self.pr_cache.seed(
679 prefs
680 .entries
681 .into_iter()
682 .map(|e| (e.target, e.resolution.into_resolution())),
683 );
684 if let Some(polled_at) = prefs.polled_at {
689 let watched = prefs
690 .watched
691 .into_iter()
692 .map(|w| PrWatch {
693 target: w.target,
694 upstream_sha: w.upstream_sha,
695 })
696 .collect();
697 *self
698 .pr_warm_start
699 .lock()
700 .unwrap_or_else(PoisonError::into_inner) =
701 Some(PrWarmStart { watched, polled_at });
702 }
703 }
704 Err(err) => {
708 let at = path.display();
709 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
710 }
711 },
712 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
713 Err(err) => {
714 let at = path.display();
715 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
716 }
717 }
718 *self
719 .pr_cache_path
720 .lock()
721 .unwrap_or_else(PoisonError::into_inner) = Some(path);
722 }
723
724 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
731 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
735 .await
736 }
737
738 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
741 let key = format!("{owner}/{name}");
742 if let Some(prs) = self.open_pr_cache.fresh(&key) {
743 return Ok(prs);
744 }
745 let slug = key.clone();
746 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
747 .await
748 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
749 self.open_pr_cache.store(key, prs.clone());
750 Ok(prs)
751 }
752
753 #[must_use]
757 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
758 self.rate_limit_cache.clone()
759 }
760
761 pub fn start_menu_refresh(&self) {
769 if tokio::runtime::Handle::try_current().is_err() {
770 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
771 return;
772 }
773 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
774 if guard.is_some() {
775 return;
776 }
777 let token = CancellationToken::new();
778 let loop_token = token.clone();
779 let registry = self.registry.clone();
780 let cache = self.menu_cache.clone();
781 let rate_limit_cache = self.rate_limit_cache.clone();
782 let interval = menu_refresh_interval();
785 let handle = tokio::spawn(async move {
786 loop {
787 let entries = registry.list();
791 let rate_limit = rate_limit_cache.get();
794 if let Ok(items) = tokio::task::spawn_blocking(move || {
795 menu_items_for(&entries, rate_limit.as_ref())
796 })
797 .await
798 {
799 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
800 }
801 tokio::select! {
802 () = loop_token.cancelled() => break,
803 () = tokio::time::sleep(interval) => {}
804 }
805 }
806 });
807 *guard = Some(RefreshTask { token, handle });
808 }
809
810 pub fn start_pr_poller(&self) {
841 self.start_pr_poller_with(
844 pr_poll_interval(),
845 pr_debounce_interval(),
846 crate::pr_status::resolve_gh_binary(),
847 );
848 }
849
850 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
861 if tokio::runtime::Handle::try_current().is_err() {
862 tracing::debug!("no tokio runtime; worktrees PR poller not started");
863 return;
864 }
865 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
866 if guard.is_some() {
867 return;
868 }
869 let token = CancellationToken::new();
870 let loop_token = token.clone();
871 let registry = self.registry.clone();
872 let tree_cache = self.tree_cache.clone();
873 let pr_cache = self.pr_cache.clone();
874 let rate_limit_cache = self.rate_limit_cache.clone();
878 let pr_cache_path = self
879 .pr_cache_path
880 .lock()
881 .unwrap_or_else(PoisonError::into_inner)
882 .clone();
883 let warm_start = self
884 .pr_warm_start
885 .lock()
886 .unwrap_or_else(PoisonError::into_inner)
887 .take();
888 let mut changes = self.registry.subscribe_changes();
891 let handle = tokio::spawn(async move {
892 let mut backoff = base;
903 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
910 match warm_start {
911 Some(ws) => {
912 let elapsed = (Utc::now() - ws.polled_at)
913 .to_std()
914 .unwrap_or(Duration::ZERO);
915 (Some(ws.watched), Instant::now().checked_sub(elapsed))
916 }
917 None => (None, None),
918 };
919 let mut moved_at: Option<Instant> = None;
922 'poll: loop {
923 tokio::select! {
926 () = loop_token.cancelled() => break,
927 () = tokio::time::sleep(base) => {}
928 result = changes.changed() => {
931 if result.is_err() {
938 break;
939 }
940 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
948 loop {
949 tokio::select! {
950 () = loop_token.cancelled() => break 'poll,
951 () = tokio::time::sleep(debounce) => break,
952 r = changes.changed() => {
953 if r.is_err() {
954 break 'poll;
955 }
956 if Instant::now() >= overall_deadline {
957 break;
958 }
959 }
960 }
961 }
962 }
963 }
964 let snapshot = tree_cache.snapshot().await;
967 let watch = pr_watch_from_snapshot(&snapshot);
968 if watch.is_empty() {
969 backoff = base;
976 last_poll = None;
977 moved_at = None;
978 continue;
979 }
980 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
984 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
988 pr_cache.retain_targets(&keep);
989 let rate_limit = rate_limit_cache.get();
995 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
996 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
997 let trigger = grew && !over_budget;
998 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
999 if !grew {
1004 watched = Some(watch);
1005 }
1006 continue;
1007 }
1008 if grew {
1009 backoff = base;
1012 moved_at = Some(Instant::now());
1013 }
1014 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1015 let bin = gh_bin.clone();
1020 let resolved = tokio::task::spawn_blocking(move || {
1021 crate::pr_status::resolve_with_budget(&bin, &targets)
1022 })
1023 .await
1024 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1025 let (pending, resolved_ok) = match resolved {
1032 Ok((resolutions, budget)) => {
1033 if let Some(b) = budget {
1039 tracing::debug!(
1040 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1041 b.cost,
1042 b.used,
1043 b.limit,
1044 b.remaining
1045 );
1046 rate_limit_cache.observe_graphql(RateLimitResource::new(
1047 b.used,
1048 b.limit,
1049 b.remaining,
1050 b.reset,
1051 ));
1052 }
1053 if pr_cache.replace(resolutions) {
1056 registry.bump();
1057 }
1058 (pr_cache.any_pending(), true)
1059 }
1060 Err(err) => {
1061 tracing::debug!("PR badge poll failed: {err:#}");
1062 (false, false)
1063 }
1064 };
1065 last_poll = Some(Instant::now());
1066 watched = Some(watch);
1069 let since_moved = moved_at.map(|at| at.elapsed());
1070 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1071 if resolved_ok {
1077 if let Some(path) = &pr_cache_path {
1078 persist_pr_cache(
1079 path,
1080 &pr_cache,
1081 watched.as_deref().unwrap_or(&[]),
1082 Utc::now(),
1083 );
1084 }
1085 }
1086 }
1087 });
1088 *guard = Some(PollerTask { token, handle });
1089 }
1090
1091 pub fn start_rate_limit_poller(&self) {
1109 self.start_rate_limit_poller_with(
1112 rate_limit_poll_interval(),
1113 crate::pr_status::resolve_gh_binary(),
1114 );
1115 }
1116
1117 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1122 if tokio::runtime::Handle::try_current().is_err() {
1123 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1124 return;
1125 }
1126 let mut guard = self
1127 .rate_limit_poller
1128 .lock()
1129 .unwrap_or_else(PoisonError::into_inner);
1130 if guard.is_some() {
1131 return;
1132 }
1133 let token = CancellationToken::new();
1134 let loop_token = token.clone();
1135 let cache = self.rate_limit_cache.clone();
1136 let registry = self.registry.clone();
1137 let handle = tokio::spawn(async move {
1138 let mut prev: Option<RateLimitSnapshot> = None;
1141 loop {
1142 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1150 let bin = gh_bin.clone();
1156 let resolved =
1157 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1158 .await
1159 .unwrap_or_else(|err| {
1160 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1161 });
1162 match resolved {
1163 Ok(snap) => {
1164 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1165 let summary = snap.summary_line();
1171 tracing::warn!(
1172 "GitHub API rate limit high: {summary} (querying \
1173 /rate_limit is free; the daemon's gh usage is not)"
1174 );
1175 }
1176 cache.replace(snap);
1181 prev = Some(snap);
1182 }
1183 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1188 }
1189 }
1190 tokio::select! {
1191 () = loop_token.cancelled() => break,
1192 () = tokio::time::sleep(interval) => {}
1193 }
1194 }
1195 });
1196 *guard = Some(PollerTask { token, handle });
1197 }
1198
1199 async fn close(&self, req: CloseRequest) -> Result<Value> {
1214 let entries = self.registry.list();
1218 let scan_path = req.path.clone();
1219 let open_windows =
1220 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1221 .await
1222 .unwrap_or_default();
1223 let open = !open_windows.is_empty();
1224 let window_key = open_windows.first().map(|(k, _)| k.clone());
1225 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1226
1227 if req.remove && !req.confirmed {
1231 let path = req.path.clone();
1232 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1233 .await
1234 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1235 .and_then(|inner| inner)
1236 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1237 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1243 return Ok(serde_json::to_value(SafetyReport {
1244 removable: git.removable,
1245 is_main: git.is_main,
1246 open,
1247 window_key,
1248 window_folder_count,
1249 risks: git.risks,
1250 info: git.info,
1251 })
1252 .unwrap_or_else(|_| json!({})));
1253 }
1254
1255 let others: Vec<String> = open_windows
1262 .iter()
1263 .map(|(k, _)| k.clone())
1264 .filter(|k| req.requester_key.as_deref() != Some(k))
1265 .collect();
1266 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1271 log_executing(
1272 &req.path,
1273 req.requester_key.as_deref(),
1274 req.remove,
1275 self_close,
1276 others.len(),
1277 );
1278 for key in &others {
1279 self.registry.mark_close_pending(key);
1280 }
1281 if !others.is_empty() {
1282 if let Err(err) = await_windows_closed(
1283 &self.registry,
1284 &req.path,
1285 req.requester_key.as_deref(),
1286 CLOSE_WAIT_TIMEOUT,
1287 CLOSE_WAIT_POLL,
1288 )
1289 .await
1290 {
1291 log_close_abort(&req.path, &err);
1292 return Err(err);
1293 }
1294 }
1295
1296 if req.remove {
1297 let path = req.path.clone();
1298 let entries = self.registry.list();
1302 let _guard = self.prune_lock.lock().await;
1308 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1309 .await
1310 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1311 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1312 log_and_map_removal(&req.path, removed)
1315 } else {
1316 log_window_closed(&req.path);
1319 Ok(json!({ "closed": true }))
1320 }
1321 }
1322
1323 fn reload(&self, req: ReloadRequest) -> Value {
1339 let live: HashSet<String> = self
1340 .registry
1341 .list()
1342 .into_iter()
1343 .map(|entry| entry.key)
1344 .collect();
1345
1346 let mut seen = HashSet::new();
1347 let mut signalled = 0usize;
1348 let mut unknown = Vec::new();
1349 for key in &req.target_keys {
1350 if !seen.insert(key.as_str()) {
1352 continue;
1353 }
1354 if live.contains(key) {
1355 self.registry.mark_reload_pending(key);
1356 signalled += 1;
1357 } else {
1358 unknown.push(key.clone());
1359 }
1360 }
1361
1362 log_reload(seen.len(), signalled, &unknown);
1363 json!({
1364 "requested": seen.len(),
1365 "signalled": signalled,
1366 "unknown": unknown,
1367 })
1368 }
1369
1370 async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1386 self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1391 .await
1392 }
1393
1394 async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1397 let report_only = req.check || !req.confirmed;
1400
1401 let eval_bin = bin.clone();
1402 let eval_paths = req.paths.clone();
1403 let (eligible, skipped) =
1404 tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1405 .await
1406 .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1407 .and_then(|inner| inner)?;
1408
1409 if report_only {
1410 log_merge_check(&req, eligible.len(), skipped.len());
1412 let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1413 return Ok(
1414 serde_json::to_value(EligibilityReport { eligible, skipped })
1415 .unwrap_or_else(|_| json!({})),
1416 );
1417 }
1418
1419 let enqueue_bin = bin.clone();
1421 let (queued, failed) =
1422 tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1423 .await
1424 .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1425 log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1426 Ok(serde_json::to_value(EnqueueResult {
1427 queued,
1428 skipped,
1429 failed,
1430 })
1431 .unwrap_or_else(|_| json!({})))
1432 }
1433
1434 async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1460 self.rebase_with(req, crate::git::resolve_git_binary())
1464 .await
1465 }
1466
1467 async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1470 if req.paths.is_empty() {
1471 bail!("`rebase` requires at least one path");
1472 }
1473 let report_only = req.check || !req.confirmed;
1476 let opts = req.options(git_bin);
1477 let selection = Selection::Paths(req.paths.clone());
1478
1479 if report_only {
1480 let plan = plan_rebase(&selection, &opts).await?;
1483 log_rebase_check(&req, &plan);
1485 return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1486 }
1487
1488 let _guard = self.rebase_lock.lock().await;
1501 let plan = plan_rebase(&selection, &opts).await?;
1502 let pending: Vec<PathBuf> = plan
1506 .worktrees
1507 .iter()
1508 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1509 .map(|w| canonical(&w.path))
1510 .collect();
1511 self.registry.mark_rebasing(&pending);
1512 let fetches = plan.fetches.clone();
1513 let exec_opts = opts.clone();
1514 let outcomes =
1515 tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1516 self.registry.clear_rebasing(&pending);
1519 let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1520
1521 log_rebase_execute(&req, &outcomes);
1522 Ok(rebase_reply(&fetches, &outcomes))
1523 }
1524
1525 async fn push(&self, req: PushRequest) -> Result<Value> {
1554 self.push_with(req, crate::git::resolve_git_binary()).await
1557 }
1558
1559 async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1562 if req.paths.is_empty() {
1563 bail!("`push` requires at least one path");
1564 }
1565 let report_only = req.check || !req.confirmed;
1568 let selection = Selection::Paths(req.paths.clone());
1569
1570 if report_only {
1571 let plan = plan_push(&selection).await?;
1575 log_push_check(&req, &plan);
1577 return Ok(push_reply(&plan.worktrees));
1578 }
1579
1580 let _guard = self.push_lock.lock().await;
1586 let plan = plan_push(&selection).await?;
1587 let pending: Vec<PathBuf> = plan
1591 .worktrees
1592 .iter()
1593 .filter(|w| w.result.is_pending())
1594 .map(|w| canonical(&w.path))
1595 .collect();
1596 self.registry.mark_pushing(&pending);
1597 let opts = worktree_push::PushOptions {
1598 git_bin: Some(git_bin),
1599 };
1600 let outcomes =
1601 tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1602 self.registry.clear_pushing(&pending);
1606 let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1607
1608 log_push_execute(&req, &outcomes);
1609 Ok(push_reply(&outcomes))
1610 }
1611
1612 async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1630 self.reposition_with(req, geometry::ax::AxBackend::new)
1631 .await
1632 }
1633
1634 async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1643 where
1644 B: geometry::WindowBackend,
1645 F: FnOnce() -> B + Send + 'static,
1646 {
1647 if req.reference_key.trim().is_empty() {
1648 bail!("`reposition` requires a non-empty `reference_key`");
1649 }
1650 let entries = self.registry.list();
1653 let reference = registered_window(&entries, &req.reference_key);
1654 if !reference.live {
1655 bail!(
1658 "no open window with key {} (it may have closed)",
1659 req.reference_key
1660 );
1661 }
1662 let targets: Vec<geometry::RegisteredWindow> = req
1665 .target_keys
1666 .iter()
1667 .map(|key| registered_window(&entries, key))
1668 .collect();
1669
1670 let check = req.check;
1671 let mut report = tokio::task::spawn_blocking(move || {
1672 let backend = make_backend();
1675 geometry::reposition(&backend, &reference, &targets, check)
1676 })
1677 .await
1678 .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1679
1680 let undo = std::mem::take(&mut report.undo);
1685 let undoable = !check && !undo.is_empty();
1686 if undoable {
1687 *self
1688 .reposition_undo
1689 .lock()
1690 .unwrap_or_else(PoisonError::into_inner) = undo;
1691 }
1692 log_reposition(&req, &report);
1693 Ok(reposition_reply(&report, undoable))
1694 }
1695
1696 async fn reposition_undo(&self) -> Result<Value> {
1704 self.reposition_undo_with(geometry::ax::AxBackend::new)
1705 .await
1706 }
1707
1708 async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1712 where
1713 B: geometry::WindowBackend,
1714 F: FnOnce() -> B + Send + 'static,
1715 {
1716 let stored = std::mem::take(
1717 &mut *self
1718 .reposition_undo
1719 .lock()
1720 .unwrap_or_else(PoisonError::into_inner),
1721 );
1722 if stored.is_empty() {
1723 return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1724 }
1725 let entries = self.registry.list();
1726 let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1727 .into_iter()
1728 .map(|(key, frame)| (registered_window(&entries, &key), frame))
1729 .collect();
1730
1731 let report = tokio::task::spawn_blocking(move || {
1732 let backend = make_backend();
1733 geometry::restore(&backend, &restore)
1734 })
1735 .await
1736 .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1737 log_reposition_undo(&report);
1738 Ok(reposition_reply(&report, false))
1739 }
1740}
1741
1742impl Default for WorktreesService {
1743 fn default() -> Self {
1744 Self::new()
1745 }
1746}
1747
1748#[async_trait]
1749impl DaemonService for WorktreesService {
1750 fn name(&self) -> &'static str {
1751 SERVICE_NAME
1752 }
1753
1754 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1755 match op {
1756 "register" => {
1757 let req: RegisterRequest =
1758 serde_json::from_value(payload).context("invalid `register` payload")?;
1759 if req.key.trim().is_empty() {
1760 bail!("`register` requires a non-empty `key`");
1761 }
1762 self.registry.register(req);
1763 Ok(json!({ "ok": true }))
1764 }
1765 "heartbeat" => {
1766 let key = require_str(&payload, "key", "heartbeat")?;
1767 let known = self.registry.heartbeat(key);
1768 let mut reply = json!({ "known": known });
1773 if self.registry.take_close_pending(key) {
1774 reply["close"] = Value::Bool(true);
1775 }
1776 if self.registry.take_reload_pending(key) {
1783 reply["reload"] = Value::Bool(true);
1784 }
1785 Ok(reply)
1786 }
1787 "unregister" => {
1788 let key = require_str(&payload, "key", "unregister")?;
1789 Ok(json!({ "removed": self.registry.unregister(key) }))
1790 }
1791 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1792 "tree" => {
1793 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1801 }
1802 "ahead-behind" => {
1803 let paths = payload
1810 .get("paths")
1811 .and_then(Value::as_array)
1812 .map(|arr| {
1813 arr.iter()
1814 .filter_map(Value::as_str)
1815 .map(PathBuf::from)
1816 .collect::<Vec<_>>()
1817 })
1818 .unwrap_or_default();
1819 Ok(json!({ "results": ahead_behind_results(paths).await }))
1820 }
1821 "set-show-closed" => {
1822 let show_closed = payload
1827 .get("show_closed")
1828 .and_then(Value::as_bool)
1829 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1830 self.registry.set_show_closed(show_closed);
1831 Ok(json!({ "ok": true }))
1832 }
1833 "set-polling" => {
1834 let owner = require_str(&payload, "owner", "set-polling")?;
1843 let name = require_str(&payload, "name", "set-polling")?;
1844 let enabled = payload
1845 .get("enabled")
1846 .and_then(Value::as_bool)
1847 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1848 if owner.trim().is_empty() || name.trim().is_empty() {
1849 bail!("`set-polling` requires a non-empty `owner` and `name`");
1850 }
1851 if self.registry.set_polling(owner, name, enabled) {
1852 self.persist_polling_prefs();
1853 }
1854 Ok(json!({ "ok": true }))
1855 }
1856 "open-prs" => {
1857 let owner = require_str(&payload, "owner", "open-prs")?;
1865 let name = require_str(&payload, "name", "open-prs")?;
1866 if owner.trim().is_empty() || name.trim().is_empty() {
1867 bail!("`open-prs` requires a non-empty `owner` and `name`");
1868 }
1869 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1870 }
1871 "open" => {
1872 let path = require_str(&payload, "path", "open")?;
1881 focus_window(Path::new(path))?;
1882 Ok(json!({ "ok": true }))
1883 }
1884 "close" => {
1885 let req: CloseRequest =
1891 serde_json::from_value(payload).context("invalid `close` payload")?;
1892 self.close(req).await
1893 }
1894 "reload" => {
1895 let req: ReloadRequest =
1903 serde_json::from_value(payload).context("invalid `reload` payload")?;
1904 Ok(self.reload(req))
1905 }
1906 "merge-queue" => {
1907 let req: MergeQueueRequest =
1914 serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1915 self.merge_queue(req).await
1916 }
1917 "rebase" => {
1918 let req: RebaseRequest =
1927 serde_json::from_value(payload).context("invalid `rebase` payload")?;
1928 self.rebase(req).await
1929 }
1930 "push" => {
1931 let req: PushRequest =
1941 serde_json::from_value(payload).context("invalid `push` payload")?;
1942 self.push(req).await
1943 }
1944 "reposition" => {
1945 let req: RepositionRequest =
1953 serde_json::from_value(payload).context("invalid `reposition` payload")?;
1954 self.reposition(req).await
1955 }
1956 "reposition-undo" => {
1957 self.reposition_undo().await
1961 }
1962 other => bail!("unknown worktrees op: {other}"),
1963 }
1964 }
1965
1966 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1967 if op != "subscribe" {
1970 return None;
1971 }
1972 Some(Box::new(WorktreesStream {
1973 cache: self.tree_cache.clone(),
1976 changes: self.registry.subscribe_changes(),
1979 }))
1980 }
1981
1982 fn menu(&self) -> MenuSnapshot {
1983 let cached = self
1988 .menu_cache
1989 .lock()
1990 .unwrap_or_else(PoisonError::into_inner)
1991 .clone();
1992 let items = cached.unwrap_or_else(|| {
1993 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1994 });
1995 MenuSnapshot {
1996 title: SUBMENU_TITLE.to_string(),
1997 items,
1998 }
1999 }
2000
2001 async fn menu_action(&self, action_id: &str) -> Result<()> {
2002 if let Some(key) = action_id.strip_prefix("focus:") {
2003 let folder = self
2006 .registry
2007 .first_folder(key)
2008 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2009 focus_window(&folder)?;
2010 return Ok(());
2011 }
2012 bail!("unknown worktrees menu action: {action_id}")
2013 }
2014
2015 async fn status(&self) -> ServiceStatus {
2016 let entries = self.registry.list();
2017 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2018 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2019 let windows = enriched_windows(entries).await;
2020 ServiceStatus {
2021 name: SERVICE_NAME.to_string(),
2022 healthy: true,
2023 summary,
2024 detail: json!({ "windows": windows }),
2025 }
2026 }
2027
2028 async fn shutdown(&self) {
2029 let task = self
2033 .refresh
2034 .lock()
2035 .unwrap_or_else(PoisonError::into_inner)
2036 .take();
2037 if let Some(task) = task {
2038 task.token.cancel();
2039 let _ = task.handle.await;
2040 }
2041 let poller = self
2044 .poller
2045 .lock()
2046 .unwrap_or_else(PoisonError::into_inner)
2047 .take();
2048 if let Some(poller) = poller {
2049 poller.token.cancel();
2050 let _ = poller.handle.await;
2051 }
2052 let rate_limit_poller = self
2054 .rate_limit_poller
2055 .lock()
2056 .unwrap_or_else(PoisonError::into_inner)
2057 .take();
2058 if let Some(poller) = rate_limit_poller {
2059 poller.token.cancel();
2060 let _ = poller.handle.await;
2061 }
2062 }
2063}
2064
2065fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2069 payload
2070 .get(field)
2071 .and_then(Value::as_str)
2072 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2073}
2074
2075#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2086struct GitStatus {
2087 #[serde(skip_serializing_if = "Option::is_none")]
2089 branch: Option<String>,
2090 #[serde(skip_serializing_if = "Option::is_none")]
2096 head_sha: Option<String>,
2097 #[serde(skip_serializing_if = "Option::is_none")]
2105 upstream_sha: Option<String>,
2106 #[serde(skip_serializing_if = "Option::is_none")]
2108 ahead: Option<usize>,
2109 #[serde(skip_serializing_if = "Option::is_none")]
2111 behind: Option<usize>,
2112 #[serde(skip_serializing_if = "Option::is_none")]
2117 main_repo: Option<String>,
2118 #[serde(skip_serializing_if = "is_false")]
2121 is_worktree: bool,
2122 #[serde(skip_serializing_if = "Option::is_none")]
2138 operation: Option<String>,
2139}
2140
2141#[allow(clippy::trivially_copy_pass_by_ref)]
2145fn is_false(b: &bool) -> bool {
2146 !*b
2147}
2148
2149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2154struct PollingLease {
2155 repo: String,
2156 expires_at: DateTime<Utc>,
2157}
2158
2159#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2165struct PollingPrefs {
2166 #[serde(default)]
2167 enabled: Vec<PollingLease>,
2168}
2169
2170fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2174 if let Some(parent) = path.parent() {
2175 crate::daemon::paths::ensure_dir_0700(parent)?;
2176 }
2177 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2178 crate::daemon::paths::write_file_0600(path, &json)
2179}
2180
2181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2194struct PersistedBadge {
2195 number: u64,
2196 is_draft: bool,
2197 checks: PrCheckState,
2198 url: String,
2199 head_oid: String,
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2207enum PersistedResolution {
2208 Pr(PersistedBadge),
2209 NoPr,
2210}
2211
2212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2214struct PersistedEntry {
2215 target: PrTarget,
2216 resolution: PersistedResolution,
2217}
2218
2219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2224struct PersistedWatch {
2225 target: PrTarget,
2226 #[serde(default, skip_serializing_if = "Option::is_none")]
2227 upstream_sha: Option<String>,
2228}
2229
2230#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2235struct PrCachePrefs {
2236 #[serde(default)]
2237 entries: Vec<PersistedEntry>,
2238 #[serde(default)]
2239 watched: Vec<PersistedWatch>,
2240 #[serde(default, skip_serializing_if = "Option::is_none")]
2241 polled_at: Option<DateTime<Utc>>,
2242}
2243
2244impl PersistedResolution {
2245 fn from_resolution(r: &PrResolution) -> Self {
2247 match r {
2248 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2249 number: b.number,
2250 is_draft: b.is_draft,
2251 checks: b.checks,
2252 url: b.url.clone(),
2253 head_oid: b.head_oid.clone(),
2254 }),
2255 PrResolution::NoPr => Self::NoPr,
2256 }
2257 }
2258
2259 fn into_resolution(self) -> PrResolution {
2261 match self {
2262 Self::Pr(b) => PrResolution::Pr(PrBadge {
2263 number: b.number,
2264 is_draft: b.is_draft,
2265 checks: b.checks,
2266 url: b.url,
2267 head_oid: b.head_oid,
2268 }),
2269 Self::NoPr => PrResolution::NoPr,
2270 }
2271 }
2272}
2273
2274fn pr_cache_prefs_from(
2277 entries: Vec<(PrTarget, PrResolution)>,
2278 watched: &[PrWatch],
2279 polled_at: DateTime<Utc>,
2280) -> PrCachePrefs {
2281 let mut entries: Vec<PersistedEntry> = entries
2282 .into_iter()
2283 .map(|(target, resolution)| PersistedEntry {
2284 target,
2285 resolution: PersistedResolution::from_resolution(&resolution),
2286 })
2287 .collect();
2288 entries.sort_by(|a, b| a.target.cmp(&b.target));
2291 let mut watched: Vec<PersistedWatch> = watched
2292 .iter()
2293 .map(|w| PersistedWatch {
2294 target: w.target.clone(),
2295 upstream_sha: w.upstream_sha.clone(),
2296 })
2297 .collect();
2298 watched.sort_by(|a, b| a.target.cmp(&b.target));
2299 PrCachePrefs {
2300 entries,
2301 watched,
2302 polled_at: Some(polled_at),
2303 }
2304}
2305
2306fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2309 if let Some(parent) = path.parent() {
2310 crate::daemon::paths::ensure_dir_0700(parent)?;
2311 }
2312 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2313 crate::daemon::paths::write_file_0600(path, &json)
2314}
2315
2316fn persist_pr_cache(
2322 path: &Path,
2323 pr_cache: &PrStatusCache,
2324 watched: &[PrWatch],
2325 polled_at: DateTime<Utc>,
2326) {
2327 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2328 if let Err(err) = write_pr_cache(path, &prefs) {
2329 let at = path.display();
2330 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2331 }
2332}
2333
2334#[derive(Debug, Clone)]
2340struct PrWarmStart {
2341 watched: Vec<PrWatch>,
2343 polled_at: DateTime<Utc>,
2346}
2347
2348#[derive(Debug, Clone)]
2353struct OpenPrEntry {
2354 at: Instant,
2355 prs: Vec<Value>,
2356}
2357
2358#[derive(Debug)]
2369struct OpenPrCache {
2370 entries: Mutex<HashMap<String, OpenPrEntry>>,
2371 ttl: Duration,
2372}
2373
2374impl OpenPrCache {
2375 fn new(ttl: Duration) -> Self {
2376 Self {
2377 entries: Mutex::new(HashMap::new()),
2378 ttl,
2379 }
2380 }
2381
2382 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2385 self.entries
2386 .lock()
2387 .unwrap_or_else(PoisonError::into_inner)
2388 .get(key)
2389 .filter(|e| e.at.elapsed() < self.ttl)
2390 .map(|e| e.prs.clone())
2391 }
2392
2393 fn store(&self, key: String, prs: Vec<Value>) {
2395 self.entries
2396 .lock()
2397 .unwrap_or_else(PoisonError::into_inner)
2398 .insert(
2399 key,
2400 OpenPrEntry {
2401 at: Instant::now(),
2402 prs,
2403 },
2404 );
2405 }
2406}
2407
2408fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2413 let output = crate::github_metrics::run_gh(
2414 bin,
2415 [
2416 "pr",
2417 "list",
2418 "--repo",
2419 slug,
2420 "--state",
2421 "open",
2422 "--json",
2423 OPEN_PR_JSON_FIELDS,
2424 "--limit",
2425 OPEN_PR_LIST_LIMIT,
2426 ],
2427 "pr list",
2428 None,
2429 )
2430 .with_context(|| {
2431 format!(
2432 "failed to run {} (is the GitHub CLI installed?)",
2433 bin.display()
2434 )
2435 })?;
2436 if !output.status.success() {
2437 let stderr = String::from_utf8_lossy(&output.stderr);
2438 bail!("gh pr list failed: {}", stderr.trim());
2439 }
2440 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2441 Value::Array(arr) => Ok(arr),
2442 _ => bail!("gh pr list did not return a JSON array"),
2443 }
2444}
2445
2446fn git_status(folder: &Path) -> GitStatus {
2452 git_status_impl(folder, true)
2453}
2454
2455fn git_status_cheap(folder: &Path) -> GitStatus {
2463 git_status_impl(folder, false)
2464}
2465
2466fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2472 let Ok(repo) = Repository::discover(folder) else {
2473 return GitStatus::default();
2474 };
2475 let base = GitStatus {
2481 main_repo: main_repo_name(repo.commondir()),
2482 is_worktree: repo.is_worktree(),
2483 operation: operation_slug(repo.state()),
2484 ..GitStatus::default()
2485 };
2486 let Ok(head) = repo.head() else {
2487 return base;
2489 };
2490 let base = GitStatus {
2496 head_sha: head.target().map(|oid| oid.to_string()),
2497 ..base
2498 };
2499 let Some(name) = head
2503 .shorthand()
2504 .ok()
2505 .filter(|_| head.is_branch())
2506 .map(str::to_string)
2507 else {
2508 return base;
2509 };
2510 let branch = git2::Branch::wrap(head);
2515 let upstream_sha = upstream_target(&branch);
2518 let (ahead, behind) = if with_ahead_behind {
2521 match upstream_ahead_behind(&repo, &branch) {
2522 Some((ahead, behind)) => (Some(ahead), Some(behind)),
2523 None => (None, None),
2524 }
2525 } else {
2526 (None, None)
2527 };
2528 GitStatus {
2529 branch: Some(name),
2530 upstream_sha,
2531 ahead,
2532 behind,
2533 ..base
2534 }
2535}
2536
2537fn operation_slug(state: RepositoryState) -> Option<String> {
2548 let slug = match state {
2549 RepositoryState::Clean => return None,
2550 RepositoryState::Merge => "merge",
2551 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2552 RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2553 RepositoryState::Bisect => "bisect",
2554 RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2555 RepositoryState::RebaseInteractive => "rebase-interactive",
2556 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2557 };
2558 Some(slug.to_string())
2559}
2560
2561fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2571 Some(branch.upstream().ok()?.get().target()?.to_string())
2572}
2573
2574fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2581 let repo = Repository::discover(folder).ok()?;
2582 let head = repo.head().ok()?;
2583 if !head.is_branch() {
2584 return None;
2585 }
2586 let branch = git2::Branch::wrap(head);
2587 upstream_ahead_behind(&repo, &branch)
2588}
2589
2590fn folder_main_behind(folder: &Path) -> Option<usize> {
2605 let repo = Repository::discover(folder).ok()?;
2606 let head = repo.head().ok()?;
2607 if !head.is_branch() {
2608 return None;
2609 }
2610 let branch = git2::Branch::wrap(head);
2611
2612 let remote = "origin";
2613 let default_branch = RemoteInfo::detect_main_branch_local(&repo, remote)?;
2614 let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2615
2616 if let Ok(upstream) = branch.upstream() {
2621 if upstream.get().name() == Ok(onto_ref.as_str()) {
2622 return None;
2623 }
2624 }
2625
2626 let head_oid = branch.get().target()?;
2627 let onto_oid = repo
2628 .revparse_single(&onto_ref)
2629 .ok()?
2630 .peel_to_commit()
2631 .ok()?
2632 .id();
2633 let (_ahead, behind) = repo.graph_ahead_behind(head_oid, onto_oid).ok()?;
2634 Some(behind)
2635}
2636
2637fn main_repo_name(commondir: &Path) -> Option<String> {
2643 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2644 if file_name == ".git" {
2645 commondir
2647 .parent()
2648 .and_then(Path::file_name)
2649 .map(|n| n.to_string_lossy().into_owned())
2650 } else {
2651 Some(
2653 file_name
2654 .strip_suffix(".git")
2655 .unwrap_or(&file_name)
2656 .to_string(),
2657 )
2658 }
2659}
2660
2661fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2664 let upstream = branch.upstream().ok()?;
2665 let local_oid = branch.get().target()?;
2666 let upstream_oid = upstream.get().target()?;
2667 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2668}
2669
2670#[derive(Serialize)]
2676struct EnrichedEntry<'a> {
2677 #[serde(flatten)]
2678 entry: &'a WindowEntry,
2679 #[serde(flatten)]
2680 git: GitStatus,
2681}
2682
2683fn enriched_entry(entry: &WindowEntry) -> Value {
2688 let git = entry
2689 .folders
2690 .first()
2691 .map(|folder| git_status(folder))
2692 .unwrap_or_default();
2693 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2694}
2695
2696async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2700 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2701 .await
2702 .unwrap_or_default()
2703}
2704
2705#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2711struct GithubIdentity {
2712 owner: String,
2714 name: String,
2716}
2717
2718#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2734struct TreeWorktree {
2735 path: String,
2737 #[serde(skip_serializing_if = "Option::is_none")]
2739 branch: Option<String>,
2740 #[serde(skip_serializing_if = "Option::is_none")]
2745 head_sha: Option<String>,
2746 #[serde(skip_serializing_if = "Option::is_none")]
2752 upstream_sha: Option<String>,
2753 is_main: bool,
2755 open: bool,
2757 #[serde(skip_serializing_if = "Option::is_none")]
2760 window_key: Option<String>,
2761 #[serde(skip_serializing_if = "Option::is_none")]
2767 pr: Option<PrBadge>,
2768 #[serde(skip_serializing_if = "is_false")]
2778 pr_none: bool,
2779 #[serde(skip_serializing_if = "Option::is_none")]
2784 operation: Option<String>,
2785 #[serde(skip_serializing_if = "is_false")]
2794 rebasing: bool,
2795 #[serde(skip_serializing_if = "is_false")]
2804 pushing: bool,
2805}
2806
2807#[derive(Debug, Clone, Default)]
2815struct InFlight {
2816 rebasing: HashSet<PathBuf>,
2818 pushing: HashSet<PathBuf>,
2820}
2821
2822impl InFlight {
2823 fn read(registry: &WorktreesRegistry) -> Self {
2827 Self {
2828 rebasing: registry.rebasing_paths(),
2829 pushing: registry.pushing_paths(),
2830 }
2831 }
2832}
2833
2834#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2838struct TreeRepo {
2839 main_repo: String,
2841 #[serde(skip_serializing_if = "Option::is_none")]
2843 github: Option<GithubIdentity>,
2844 root: String,
2846 #[serde(skip_serializing_if = "is_false")]
2854 polling_enabled: bool,
2855 worktrees: Vec<TreeWorktree>,
2858}
2859
2860fn github_identity(url: &str) -> Option<GithubIdentity> {
2867 let url = url.trim();
2868 let rest = [
2870 "https://github.com/",
2871 "http://github.com/",
2872 "ssh://git@github.com/",
2873 "git://github.com/",
2874 "git@github.com:",
2875 ]
2876 .iter()
2877 .find_map(|prefix| url.strip_prefix(prefix))?;
2878 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2879 let rest = rest.trim_end_matches('/');
2880 let mut parts = rest.splitn(2, '/');
2881 let owner = parts.next()?.trim();
2882 let name = parts.next()?.trim();
2883 if owner.is_empty() || name.is_empty() || name.contains('/') {
2885 return None;
2886 }
2887 Some(GithubIdentity {
2888 owner: owner.to_string(),
2889 name: name.to_string(),
2890 })
2891}
2892
2893fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2896 if let Ok(origin) = repo.find_remote("origin") {
2897 if let Some(id) = origin.url().ok().and_then(github_identity) {
2898 return Some(id);
2899 }
2900 }
2901 let names = repo.remotes().ok();
2905 names
2906 .iter()
2907 .flat_map(|arr| arr.iter())
2908 .flatten()
2909 .flatten()
2910 .filter_map(|name| repo.find_remote(name).ok())
2911 .find_map(|remote| remote.url().ok().and_then(github_identity))
2912}
2913
2914fn canonical(path: &Path) -> PathBuf {
2918 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2919}
2920
2921fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2926 let mut index = HashMap::new();
2927 for entry in entries {
2928 for folder in &entry.folders {
2929 index
2930 .entry(canonical(folder))
2931 .or_insert_with(|| entry.key.clone());
2932 }
2933 }
2934 index
2935}
2936
2937fn worktree_entry(
2942 path: &Path,
2943 is_main: bool,
2944 open_index: &HashMap<PathBuf, String>,
2945 in_flight: &InFlight,
2946) -> TreeWorktree {
2947 let status = git_status_cheap(path);
2948 let canonical = canonical(path);
2949 let window_key = open_index.get(&canonical).cloned();
2950 TreeWorktree {
2951 path: path.display().to_string(),
2952 branch: status.branch,
2953 head_sha: status.head_sha,
2954 upstream_sha: status.upstream_sha,
2955 is_main,
2956 open: window_key.is_some(),
2957 window_key,
2958 pr: None,
2961 pr_none: false,
2962 operation: status.operation,
2963 rebasing: in_flight.rebasing.contains(&canonical),
2966 pushing: in_flight.pushing.contains(&canonical),
2967 }
2968}
2969
2970fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2999 for repo in repos {
3000 if let Some(github) = &repo.github {
3001 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3002 }
3003 }
3004}
3005
3006fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3007 for repo in repos {
3008 if !repo.polling_enabled {
3012 continue;
3013 }
3014 let Some(github) = repo.github.clone() else {
3015 continue;
3016 };
3017 for worktree in &mut repo.worktrees {
3018 let Some(branch) = &worktree.branch else {
3019 continue;
3020 };
3021 match pr_cache.get(&github.owner, &github.name, branch) {
3022 Some(PrResolution::Pr(mut badge)) => {
3023 if badge.is_stale_for(worktree.head_sha.as_deref()) {
3024 badge.checks = PrCheckState::Pending;
3025 }
3026 worktree.pr = Some(badge);
3027 }
3028 Some(PrResolution::NoPr) => worktree.pr_none = true,
3029 None => {}
3030 }
3031 }
3032 }
3033}
3034
3035fn repo_tree(
3041 discovered: &Repository,
3042 open_index: &HashMap<PathBuf, String>,
3043 in_flight: &InFlight,
3044) -> Option<TreeRepo> {
3045 let commondir = canonical(discovered.commondir());
3048 let main_root = commondir.parent()?.to_path_buf();
3049 let main_repo = Repository::open(&main_root).ok()?;
3050
3051 let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3053 let names = main_repo.worktrees().ok();
3058 let mut linked: Vec<PathBuf> = names
3059 .iter()
3060 .flat_map(|arr| arr.iter())
3061 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
3064 .map(|wt| wt.path().to_path_buf())
3065 .collect();
3066 linked.sort();
3067 worktrees.extend(
3068 linked
3069 .iter()
3070 .map(|path| worktree_entry(path, false, open_index, in_flight)),
3071 );
3072
3073 Some(TreeRepo {
3074 main_repo: main_repo_name(&commondir)?,
3075 github: remote_github_identity(&main_repo),
3076 root: main_root.display().to_string(),
3077 polling_enabled: false,
3080 worktrees,
3081 })
3082}
3083
3084fn build_tree(
3090 folders: Vec<PathBuf>,
3091 windows: Vec<WindowEntry>,
3092 in_flight: InFlight,
3093) -> Vec<TreeRepo> {
3094 let open_index = open_window_index(&windows);
3095 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3096 for folder in &folders {
3097 let Ok(repo) = Repository::discover(folder) else {
3098 continue;
3099 };
3100 let key = canonical(repo.commondir());
3101 if repos.contains_key(&key) {
3102 continue;
3103 }
3104 if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3105 repos.insert(key, tree);
3106 }
3107 }
3108 repos.into_values().collect()
3109}
3110
3111async fn tree_repos(
3116 folders: Vec<PathBuf>,
3117 windows: Vec<WindowEntry>,
3118 pr_cache: Arc<PrStatusCache>,
3119 enabled_polling: HashSet<String>,
3120 in_flight: InFlight,
3121) -> Vec<Value> {
3122 tokio::task::spawn_blocking(move || {
3123 let mut repos = build_tree(folders, windows, in_flight);
3124 stamp_polling(&mut repos, &enabled_polling);
3127 fold_pr_badges(&mut repos, &pr_cache);
3128 repos
3129 .iter()
3130 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3131 .collect()
3132 })
3133 .await
3134 .unwrap_or_default()
3135}
3136
3137#[derive(Serialize)]
3146struct AheadBehindEntry {
3147 #[serde(skip_serializing_if = "Option::is_none")]
3148 ahead: Option<usize>,
3149 #[serde(skip_serializing_if = "Option::is_none")]
3150 behind: Option<usize>,
3151 #[serde(skip_serializing_if = "Option::is_none")]
3152 main_behind: Option<usize>,
3153}
3154
3155async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
3171 tokio::task::spawn_blocking(move || {
3172 let mut results = serde_json::Map::new();
3173 for path in paths {
3174 let (ahead, behind) =
3175 folder_ahead_behind(&path).map_or((None, None), |(a, b)| (Some(a), Some(b)));
3176 let main_behind = folder_main_behind(&path);
3177 if ahead.is_none() && main_behind.is_none() {
3178 continue;
3179 }
3180 results.insert(
3181 path.display().to_string(),
3182 json!(AheadBehindEntry {
3183 ahead,
3184 behind,
3185 main_behind,
3186 }),
3187 );
3188 }
3189 Value::Object(results)
3190 })
3191 .await
3192 .unwrap_or_else(|_| json!({}))
3193}
3194
3195struct WorktreesStream {
3209 cache: Arc<TreeSnapshotCache>,
3212 changes: watch::Receiver<u64>,
3216}
3217
3218#[async_trait]
3219impl ServiceStream for WorktreesStream {
3220 async fn changed(&mut self) {
3221 if self.changes.changed().await.is_err() {
3227 std::future::pending::<()>().await;
3228 }
3229 }
3230
3231 async fn snapshot(&self) -> Value {
3232 self.cache.snapshot().await
3237 }
3238}
3239
3240struct TreeSnapshotCache {
3264 registry: Arc<WorktreesRegistry>,
3267 pr_cache: Arc<PrStatusCache>,
3270 ttl: Duration,
3274 state: AsyncMutex<Option<CachedTree>>,
3279 computes: AtomicU64,
3283}
3284
3285struct CachedTree {
3288 generation: u64,
3292 computed_at: Instant,
3294 value: Arc<Value>,
3297}
3298
3299impl TreeSnapshotCache {
3300 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3304 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3305 }
3306
3307 fn with_ttl(
3310 registry: Arc<WorktreesRegistry>,
3311 pr_cache: Arc<PrStatusCache>,
3312 ttl: Duration,
3313 ) -> Self {
3314 Self {
3315 registry,
3316 pr_cache,
3317 ttl,
3318 state: AsyncMutex::new(None),
3319 computes: AtomicU64::new(0),
3320 }
3321 }
3322
3323 async fn snapshot(&self) -> Value {
3327 let mut state = self.state.lock().await;
3332 let generation = self.registry.change_generation();
3333 let fresh = state.as_ref().and_then(|cached| {
3336 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3337 .then(|| Arc::clone(&cached.value))
3338 });
3339 let value = if let Some(value) = fresh {
3340 value
3341 } else {
3342 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3343 self.computes.fetch_add(1, Ordering::Relaxed);
3344 *state = Some(CachedTree {
3345 generation,
3346 computed_at: Instant::now(),
3347 value: Arc::clone(&value),
3348 });
3349 value
3350 };
3351 drop(state);
3353 (*value).clone()
3354 }
3355
3356 #[cfg(test)]
3359 fn compute_count(&self) -> u64 {
3360 self.computes.load(Ordering::Relaxed)
3361 }
3362}
3363
3364async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3370 let folders = registry.open_folders();
3371 let windows = registry.list();
3372 let show_closed = registry.show_closed();
3373 let enabled_polling = registry.enabled_polling_repos();
3374 let in_flight = InFlight::read(registry);
3377 json!({
3378 "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3379 "show_closed": show_closed,
3380 })
3381}
3382
3383fn display_name(entry: &WindowEntry) -> String {
3386 if let Some(repo) = &entry.repo {
3387 return repo.clone();
3388 }
3389 if let Some(folder) = entry.folders.first() {
3390 return folder.file_name().map_or_else(
3391 || folder.display().to_string(),
3392 |n| n.to_string_lossy().into_owned(),
3393 );
3394 }
3395 "(no folder)".to_string()
3396}
3397
3398const REPO_SEP: char = '·';
3400const WORKTREE_SEP: char = '⑂';
3403
3404fn menu_items_for(
3409 entries: &[WindowEntry],
3410 rate_limit: Option<&RateLimitSnapshot>,
3411) -> Vec<MenuItem> {
3412 let mut items = Vec::new();
3413 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3417 if !label.is_empty() {
3418 items.push(MenuItem::Label(label));
3419 items.push(MenuItem::Separator);
3420 }
3421 }
3422 if entries.is_empty() {
3423 items.push(MenuItem::Label("No open windows".to_string()));
3424 } else {
3425 items.extend(window_menu_items(entries));
3426 }
3427 items
3428}
3429
3430fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3437 entries
3438 .iter()
3439 .map(|entry| {
3440 let label = window_label(entry);
3441 if entry.folders.is_empty() {
3442 MenuItem::Label(label)
3443 } else {
3444 MenuItem::Action(MenuAction {
3445 id: format!("focus:{}", entry.key),
3446 label,
3447 enabled: true,
3448 })
3449 }
3450 })
3451 .collect()
3452}
3453
3454fn window_label(entry: &WindowEntry) -> String {
3460 let status = entry
3461 .folders
3462 .first()
3463 .map(|folder| git_status(folder))
3464 .unwrap_or_default();
3465 let name = status
3468 .main_repo
3469 .clone()
3470 .unwrap_or_else(|| display_name(entry));
3471 if let Some(branch) = &status.branch {
3472 let sep = if status.is_worktree {
3473 WORKTREE_SEP
3474 } else {
3475 REPO_SEP
3476 };
3477 return match sync_indicator(status.ahead, status.behind) {
3478 Some(sync) => format!("{name} {sep} {branch} {sync}"),
3479 None => format!("{name} {sep} {branch}"),
3480 };
3481 }
3482 match &entry.title {
3484 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3485 _ => name,
3486 }
3487}
3488
3489fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3492 match (ahead, behind) {
3493 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3494 _ => None,
3495 }
3496}
3497
3498const CODE_BINARY_CANDIDATES: &[&str] = &[
3501 "/usr/local/bin/code",
3502 "/opt/homebrew/bin/code",
3503 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3504 "/usr/bin/code",
3505];
3506
3507pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3512 focus_window_with(&resolve_code_binary(), folder)
3513}
3514
3515fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3522 if !folder.is_absolute() {
3527 bail!(
3528 "refusing to focus a non-absolute folder path: {}",
3529 folder.display()
3530 );
3531 }
3532 if !folder.is_dir() {
3533 bail!("worktree folder no longer exists: {}", folder.display());
3534 }
3535 let child = Command::new(program)
3538 .arg(folder)
3539 .stdin(Stdio::null())
3540 .stdout(Stdio::null())
3541 .stderr(Stdio::null())
3542 .spawn()
3543 .with_context(|| {
3544 format!(
3545 "failed to launch `{}` to focus {}",
3546 program.display(),
3547 folder.display()
3548 )
3549 })?;
3550 std::thread::spawn(move || {
3552 let mut child = child;
3553 let _ = child.wait();
3554 });
3555 Ok(())
3556}
3557
3558fn resolve_code_binary() -> PathBuf {
3563 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3564}
3565
3566fn resolve_code_binary_from(
3569 env_override: Option<std::ffi::OsString>,
3570 candidates: &[&str],
3571) -> PathBuf {
3572 if let Some(path) = env_override {
3573 return PathBuf::from(path);
3574 }
3575 for candidate in candidates {
3576 let path = Path::new(candidate);
3577 if path.exists() {
3578 return path.to_path_buf();
3579 }
3580 }
3581 PathBuf::from("code")
3582}
3583
3584#[derive(Debug, Clone, Deserialize)]
3594struct RepositionRequest {
3595 reference_key: String,
3597 #[serde(default)]
3600 target_keys: Vec<String>,
3601 #[serde(default)]
3604 check: bool,
3605}
3606
3607fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3614 entries.iter().find(|entry| entry.key == key).map_or_else(
3615 || geometry::RegisteredWindow {
3616 key: key.to_string(),
3617 live: false,
3618 title: None,
3619 pid: None,
3620 },
3621 |entry| geometry::RegisteredWindow {
3622 key: entry.key.clone(),
3623 live: true,
3624 title: entry.title.clone(),
3625 pid: entry.pid,
3626 },
3627 )
3628}
3629
3630fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3636 let mut reply = json!({
3637 "trusted": report.trusted,
3638 "results": report.results,
3639 "moved": report.moved(),
3640 "skipped": report.skipped(),
3641 });
3642 if let Some(reference) = &report.reference {
3643 reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3644 }
3645 if let Some(blocked) = &report.blocked {
3646 reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3647 }
3648 if undoable {
3651 reply["undoable"] = Value::Bool(true);
3652 }
3653 reply
3654}
3655
3656fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3660 let phase = if !report.trusted {
3663 "untrusted"
3664 } else if report.blocked.is_some() {
3665 "blocked"
3666 } else if req.check {
3667 "check"
3668 } else {
3669 "apply"
3670 };
3671 tracing::info!(
3672 phase,
3673 reference = req.reference_key.as_str(),
3674 requested = req.target_keys.len(),
3675 blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3676 moved = report.moved(),
3677 skipped = report.skipped(),
3678 outcomes = outcome_kinds(report).as_str(),
3679 "reposition"
3680 );
3681}
3682
3683fn log_reposition_undo(report: &geometry::RepositionReport) {
3685 tracing::info!(
3686 trusted = report.trusted,
3687 restored = report.moved(),
3688 skipped = report.skipped(),
3689 outcomes = outcome_kinds(report).as_str(),
3690 "reposition undo"
3691 );
3692}
3693
3694fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3698 if report.results.is_empty() {
3699 return "-".to_string();
3700 }
3701 report
3702 .results
3703 .iter()
3704 .map(|r| r.outcome)
3705 .collect::<Vec<_>>()
3706 .join(",")
3707}
3708
3709#[derive(Debug, Clone, Deserialize)]
3719struct ReloadRequest {
3720 #[serde(default)]
3724 target_keys: Vec<String>,
3725}
3726
3727fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3731 let unknown = if unknown.is_empty() {
3735 "-".to_string()
3736 } else {
3737 unknown.join(",")
3738 };
3739 tracing::info!(
3740 requested,
3741 signalled,
3742 unknown = %unknown,
3743 "worktrees reload: signalled windows"
3744 );
3745}
3746
3747#[derive(Debug, Clone, Deserialize)]
3753struct CloseRequest {
3754 path: PathBuf,
3756 #[serde(default)]
3760 requester_key: Option<String>,
3761 #[serde(default)]
3765 remove: bool,
3766 #[serde(default)]
3769 confirmed: bool,
3770}
3771
3772#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3777struct Note {
3778 kind: String,
3780 detail: String,
3782}
3783
3784impl Note {
3785 fn new(kind: &str, detail: impl Into<String>) -> Self {
3786 Self {
3787 kind: kind.to_string(),
3788 detail: detail.into(),
3789 }
3790 }
3791}
3792
3793fn note_kinds(notes: &[Note]) -> String {
3797 if notes.is_empty() {
3798 return "-".to_string();
3799 }
3800 notes
3801 .iter()
3802 .map(|n| n.kind.as_str())
3803 .collect::<Vec<_>>()
3804 .join(",")
3805}
3806
3807fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3812 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3813}
3814
3815fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3821 tracing::error!(
3822 path = %path.display(),
3823 "worktrees close: {phase} failed: {err:#}"
3824 );
3825 err
3826}
3827
3828fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3839 match removed {
3840 Ok(Removal::Pruned) => {
3841 tracing::info!(
3842 path = %path.display(),
3843 outcome = "pruned",
3844 "worktrees close: linked worktree pruned"
3845 );
3846 Ok(json!({ "removed": true }))
3847 }
3848 Ok(Removal::AlreadyGone) => {
3849 tracing::info!(
3850 path = %path.display(),
3851 outcome = "already-gone",
3852 "worktrees close: nothing to prune, worktree already removed"
3853 );
3854 Ok(json!({ "removed": true }))
3855 }
3856 Err(err) => {
3857 tracing::warn!(
3858 path = %path.display(),
3859 outcome = "failed",
3860 "worktrees close: worktree prune failed: {err:#}"
3861 );
3862 Err(err)
3863 }
3864 }
3865}
3866
3867fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3873 tracing::info!(
3874 path = %path.display(),
3875 window_key = window_key.unwrap_or("-"),
3876 removable = git.removable,
3877 is_main = git.is_main,
3878 open,
3879 risks = %note_kinds(&git.risks),
3880 "worktrees close: safety check"
3881 );
3882}
3883
3884fn log_executing(
3889 path: &Path,
3890 requester: Option<&str>,
3891 remove: bool,
3892 self_close: bool,
3893 cross_window: usize,
3894) {
3895 tracing::info!(
3896 path = %path.display(),
3897 requester = requester.unwrap_or("-"),
3898 remove,
3899 self_close,
3900 cross_window,
3901 "worktrees close: executing"
3902 );
3903}
3904
3905fn log_close_abort(path: &Path, err: &anyhow::Error) {
3909 tracing::warn!(
3910 path = %path.display(),
3911 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3912 );
3913}
3914
3915fn log_window_closed(path: &Path) {
3919 tracing::info!(
3920 path = %path.display(),
3921 "worktrees close: window closed, no removal"
3922 );
3923}
3924
3925#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3929struct SafetyReport {
3930 removable: bool,
3933 is_main: bool,
3935 open: bool,
3937 #[serde(skip_serializing_if = "Option::is_none")]
3939 window_key: Option<String>,
3940 window_folder_count: usize,
3943 risks: Vec<Note>,
3946 info: Vec<Note>,
3949}
3950
3951#[derive(Debug, Clone, PartialEq, Eq)]
3954struct GitSafety {
3955 is_main: bool,
3956 removable: bool,
3957 risks: Vec<Note>,
3958 info: Vec<Note>,
3959}
3960
3961#[derive(Debug, Clone, Deserialize)]
3969struct RebaseRequest {
3970 paths: Vec<PathBuf>,
3972 #[serde(default)]
3975 requester_key: Option<String>,
3976 #[serde(default)]
3978 check: bool,
3979 #[serde(default)]
3981 confirmed: bool,
3982 #[serde(default)]
3987 keep_conflicts: bool,
3988 #[serde(default)]
3991 autostash: bool,
3992 #[serde(default)]
3994 onto: Option<String>,
3995}
3996
3997impl RebaseRequest {
3998 fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4000 worktree_rebase::RebaseOptions {
4001 onto: self.onto.clone(),
4002 autostash: self.autostash,
4003 dry_run: false,
4006 keep_conflicts: self.keep_conflicts,
4007 git_bin: Some(git_bin),
4008 }
4009 }
4010}
4011
4012async fn plan_rebase(
4016 selection: &Selection,
4017 opts: &worktree_rebase::RebaseOptions,
4018) -> Result<worktree_rebase::Plan> {
4019 let selection = selection.clone();
4020 let opts = opts.clone();
4021 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4022 .await
4023 .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4024 .and_then(|inner| inner)
4025}
4026
4027fn rebase_reply(
4032 fetches: &[worktree_rebase::FetchOutcome],
4033 worktrees: &[worktree_rebase::WorktreeOutcome],
4034) -> Value {
4035 json!({ "fetches": fetches, "worktrees": worktrees })
4036}
4037
4038fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4042 let pending = plan
4043 .worktrees
4044 .iter()
4045 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4046 .count();
4047 let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4048 tracing::info!(
4049 requester = req.requester_key.as_deref().unwrap_or("-"),
4050 requested = req.paths.len(),
4051 pending,
4052 fetches = plan.fetches.len(),
4053 failed_fetches,
4054 "rebase check"
4055 );
4056}
4057
4058fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4062 use worktree_rebase::RebaseResult;
4063 let mut rebased = 0;
4064 let mut conflicts = 0;
4065 let mut left_in_place = 0;
4066 let mut skipped = 0;
4067 for outcome in outcomes {
4068 match &outcome.result {
4069 RebaseResult::Rebased { .. } => rebased += 1,
4070 RebaseResult::Conflict {
4071 left_in_place: k, ..
4072 } => {
4073 conflicts += 1;
4074 if *k {
4075 left_in_place += 1;
4076 }
4077 }
4078 RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4079 RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4080 }
4081 }
4082 tracing::info!(
4083 requester = req.requester_key.as_deref().unwrap_or("-"),
4084 requested = req.paths.len(),
4085 rebased,
4086 conflicts,
4087 left_in_place,
4088 skipped,
4089 "rebase execute"
4090 );
4091}
4092
4093#[derive(Debug, Clone, Deserialize)]
4105struct PushRequest {
4106 paths: Vec<PathBuf>,
4108 #[serde(default)]
4111 requester_key: Option<String>,
4112 #[serde(default)]
4114 check: bool,
4115 #[serde(default)]
4117 confirmed: bool,
4118}
4119
4120async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4124 let selection = selection.clone();
4125 tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4126 .await
4127 .map_err(|e| anyhow!("push planning task panicked: {e}"))
4128 .and_then(|inner| inner)
4129}
4130
4131fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4139 json!({ "worktrees": worktrees })
4140}
4141
4142fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4146 use worktree_push::PushResult;
4147 let pending = plan
4148 .worktrees
4149 .iter()
4150 .filter(|w| w.result.is_pending())
4151 .count();
4152 let forced = plan
4153 .worktrees
4154 .iter()
4155 .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4156 .count();
4157 let skipped = plan
4158 .worktrees
4159 .iter()
4160 .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4161 .count();
4162 tracing::info!(
4163 requester = req.requester_key.as_deref().unwrap_or("-"),
4164 requested = req.paths.len(),
4165 pending,
4166 forced,
4167 skipped,
4168 "push check"
4169 );
4170}
4171
4172fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4177 use worktree_push::PushResult;
4178 let mut pushed = 0;
4179 let mut forced = 0;
4180 let mut created = 0;
4181 let mut rejected = 0;
4182 let mut stale_rejected = 0;
4183 for outcome in outcomes {
4184 match &outcome.result {
4185 PushResult::Pushed { forced: f } => {
4186 pushed += 1;
4187 if *f {
4188 forced += 1;
4189 }
4190 }
4191 PushResult::Created => created += 1,
4192 PushResult::Rejected { stale, .. } => {
4193 rejected += 1;
4194 if *stale {
4195 stale_rejected += 1;
4196 }
4197 }
4198 PushResult::UpToDate
4199 | PushResult::WouldFastForward { .. }
4200 | PushResult::WouldForce { .. }
4201 | PushResult::WouldCreate
4202 | PushResult::Skipped { .. } => {}
4203 }
4204 }
4205 tracing::info!(
4206 requester = req.requester_key.as_deref().unwrap_or("-"),
4207 requested = req.paths.len(),
4208 pushed,
4209 forced,
4210 created,
4211 rejected,
4212 stale_rejected,
4213 "push execute"
4214 );
4215}
4216
4217#[derive(Debug, Clone, Deserialize)]
4223struct MergeQueueRequest {
4224 paths: Vec<PathBuf>,
4226 #[serde(default)]
4229 requester_key: Option<String>,
4230 #[serde(default)]
4232 check: bool,
4233 #[serde(default)]
4235 confirmed: bool,
4236}
4237
4238#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4240struct PrRef {
4241 path: String,
4243 number: u64,
4245 url: String,
4247 branch: String,
4249}
4250
4251impl From<&Eligible> for PrRef {
4252 fn from(e: &Eligible) -> Self {
4253 Self {
4254 path: e.path.to_string_lossy().to_string(),
4255 number: e.number,
4256 url: e.url.clone(),
4257 branch: e.branch.clone(),
4258 }
4259 }
4260}
4261
4262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4265struct Skip {
4266 path: String,
4267 kind: String,
4268 detail: String,
4269}
4270
4271impl Skip {
4272 fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4273 Self {
4274 path: path.to_string_lossy().to_string(),
4275 kind: kind.to_string(),
4276 detail: detail.into(),
4277 }
4278 }
4279}
4280
4281#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4285struct EligibilityReport {
4286 eligible: Vec<PrRef>,
4287 skipped: Vec<Skip>,
4288}
4289
4290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4292struct QueuedPr {
4293 path: String,
4294 number: u64,
4295 #[serde(skip_serializing_if = "is_false")]
4298 already_queued: bool,
4299}
4300
4301#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4304struct EnqueueFailure {
4305 path: String,
4306 number: u64,
4307 error: String,
4308}
4309
4310#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4313struct EnqueueResult {
4314 queued: Vec<QueuedPr>,
4315 skipped: Vec<Skip>,
4316 failed: Vec<EnqueueFailure>,
4317}
4318
4319#[derive(Debug)]
4322struct LocalOk {
4323 path: PathBuf,
4324 target: PrTarget,
4325 head_sha: String,
4326}
4327
4328#[derive(Debug)]
4330struct Eligible {
4331 path: PathBuf,
4332 number: u64,
4333 url: String,
4334 branch: String,
4335 pr_id: String,
4337 already_queued: bool,
4339}
4340
4341fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4347 let Ok(repo) = Repository::discover(path) else {
4348 return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4349 };
4350 let (dirty, untracked) = count_dirty_untracked(&repo);
4352 if dirty > 0 {
4353 return Err(Skip::new(
4354 path,
4355 "dirty",
4356 format!("{dirty} modified tracked file(s) — commit or stash first"),
4357 ));
4358 }
4359 if untracked > 0 {
4360 return Err(Skip::new(
4361 path,
4362 "untracked",
4363 format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4364 ));
4365 }
4366 let Ok(head) = repo.head() else {
4369 return Err(Skip::new(
4370 path,
4371 "no-commits",
4372 "the branch has no commits yet",
4373 ));
4374 };
4375 let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4376 return Err(Skip::new(
4377 path,
4378 "no-commits",
4379 "HEAD does not resolve to a commit",
4380 ));
4381 };
4382 let Some(branch_name) = head
4385 .shorthand()
4386 .ok()
4387 .filter(|_| head.is_branch())
4388 .map(str::to_string)
4389 else {
4390 return Err(Skip::new(
4391 path,
4392 "detached",
4393 "HEAD is detached — no branch to enqueue",
4394 ));
4395 };
4396 let branch = git2::Branch::wrap(head);
4397 let Some(upstream_sha) = upstream_target(&branch) else {
4399 return Err(Skip::new(
4400 path,
4401 "no-upstream",
4402 "the branch tracks no upstream — push it first",
4403 ));
4404 };
4405 if upstream_sha != head_sha {
4406 return Err(Skip::new(
4407 path,
4408 "unpushed",
4409 "local commits are not on the remote yet — push first",
4410 ));
4411 }
4412 if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4415 if ahead > 0 {
4416 return Err(Skip::new(
4417 path,
4418 "unpushed",
4419 format!("{ahead} unpushed commit(s) — push first"),
4420 ));
4421 }
4422 }
4423 let Some(id) = remote_github_identity(&repo) else {
4426 return Err(Skip::new(
4427 path,
4428 "no-github",
4429 "the repository has no github.com remote",
4430 ));
4431 };
4432 Ok(LocalOk {
4433 path: path.to_path_buf(),
4434 target: PrTarget {
4435 owner: id.owner,
4436 name: id.name,
4437 branch: branch_name,
4438 },
4439 head_sha,
4440 })
4441}
4442
4443fn is_conflicting(state: Option<&str>) -> bool {
4448 matches!(state, Some("CONFLICTING" | "DIRTY"))
4449}
4450
4451fn check_label(state: PrCheckState) -> &'static str {
4453 match state {
4454 PrCheckState::Success => "passing",
4455 PrCheckState::Failure => "failing",
4456 PrCheckState::Pending => "still running",
4457 PrCheckState::None => "not reported",
4458 }
4459}
4460
4461fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4466 tracing::info!(
4467 requester = req.requester_key.as_deref().unwrap_or("-"),
4468 requested = req.paths.len(),
4469 eligible,
4470 skipped,
4471 "merge-queue check"
4472 );
4473}
4474
4475fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4479 tracing::info!(
4480 requester = req.requester_key.as_deref().unwrap_or("-"),
4481 queued,
4482 failed,
4483 skipped,
4484 "merge-queue enqueue"
4485 );
4486}
4487
4488fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4499 let mut skipped = Vec::new();
4500 let mut locals = Vec::new();
4501 for path in paths {
4502 match evaluate_local(path) {
4503 Ok(ok) => locals.push(ok),
4504 Err(skip) => skipped.push(skip),
4505 }
4506 }
4507 if locals.is_empty() {
4508 return Ok((Vec::new(), skipped));
4509 }
4510 let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4511 let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4512 let mut eligible = Vec::new();
4513 for local in locals {
4514 let Some(info) = resolved.get(&local.target) else {
4515 skipped.push(Skip::new(
4516 &local.path,
4517 "no-pr",
4518 "no open PR heads this branch",
4519 ));
4520 continue;
4521 };
4522 if info.head_oid != local.head_sha {
4523 skipped.push(Skip::new(
4524 &local.path,
4525 "stale",
4526 "the open PR's head differs from the local head — re-check",
4527 ));
4528 } else if info.is_draft {
4529 skipped.push(Skip::new(
4530 &local.path,
4531 "draft",
4532 format!("PR #{} is a draft", info.number),
4533 ));
4534 } else if is_conflicting(info.merge_state.as_deref()) {
4535 skipped.push(Skip::new(
4536 &local.path,
4537 "conflicting",
4538 format!("PR #{} has merge conflicts", info.number),
4539 ));
4540 } else if info.checks != PrCheckState::Success {
4541 skipped.push(Skip::new(
4542 &local.path,
4543 "checks-failing",
4544 format!(
4545 "PR #{} checks are {}",
4546 info.number,
4547 check_label(info.checks)
4548 ),
4549 ));
4550 } else {
4551 eligible.push(Eligible {
4552 path: local.path,
4553 number: info.number,
4554 url: info.url.clone(),
4555 branch: local.target.branch.clone(),
4556 pr_id: info.pr_id.clone(),
4557 already_queued: info.already_queued,
4558 });
4559 }
4560 }
4561 Ok((eligible, skipped))
4562}
4563
4564fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4569 let mut queued = Vec::new();
4570 let mut failed = Vec::new();
4571 for e in eligible {
4572 let path = e.path.to_string_lossy().to_string();
4573 if e.already_queued {
4574 queued.push(QueuedPr {
4575 path,
4576 number: e.number,
4577 already_queued: true,
4578 });
4579 continue;
4580 }
4581 match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4582 Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4583 path,
4584 number: e.number,
4585 already_queued: false,
4586 }),
4587 Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4588 path,
4589 number: e.number,
4590 error: msg,
4591 }),
4592 Err(err) => failed.push(EnqueueFailure {
4593 path,
4594 number: e.number,
4595 error: format!("{err:#}"),
4596 }),
4597 }
4598 }
4599 (queued, failed)
4600}
4601
4602fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4606 let target = canonical(path);
4607 entries
4608 .iter()
4609 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4610 .map(|e| (e.key.clone(), e.folders.len()))
4611 .collect()
4612}
4613
4614const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4621
4622const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4625
4626async fn await_windows_closed(
4633 registry: &WorktreesRegistry,
4634 path: &Path,
4635 requester: Option<&str>,
4636 timeout: Duration,
4637 poll: Duration,
4638) -> Result<()> {
4639 let deadline = std::time::Instant::now() + timeout;
4640 loop {
4641 let entries = registry.list();
4645 let path = path.to_path_buf();
4646 let requester = requester.map(str::to_string);
4647 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4648 windows_with_path(&entries, &path)
4649 .into_iter()
4650 .map(|(k, _)| k)
4651 .filter(|k| requester.as_deref() != Some(k))
4652 .collect()
4653 })
4654 .await
4655 .unwrap_or_default();
4656
4657 if remaining.is_empty() {
4658 return Ok(());
4659 }
4660 if std::time::Instant::now() >= deadline {
4661 bail!("window(s) did not close in time: {}", remaining.join(", "));
4662 }
4663 tokio::time::sleep(poll).await;
4664 }
4665}
4666
4667fn git_safety(path: &Path) -> Result<GitSafety> {
4674 if !path.exists() {
4675 return Ok(GitSafety {
4676 is_main: false,
4677 removable: true,
4678 risks: vec![],
4679 info: vec![Note::new("already-removed", "worktree no longer exists")],
4680 });
4681 }
4682 let repo = Repository::open(path)
4683 .with_context(|| format!("not a git worktree: {}", path.display()))?;
4684 if !repo.is_worktree() {
4686 return Ok(GitSafety {
4687 is_main: true,
4688 removable: false,
4689 risks: vec![],
4690 info: vec![Note::new(
4691 "main-working-tree",
4692 "the repository's main working tree is never deleted",
4693 )],
4694 });
4695 }
4696
4697 let mut risks = Vec::new();
4698 let mut info = Vec::new();
4699
4700 let (dirty, untracked) = count_dirty_untracked(&repo);
4701 if dirty > 0 {
4702 risks.push(Note::new(
4703 "dirty",
4704 format!("{dirty} modified tracked file(s) would be lost"),
4705 ));
4706 }
4707 if untracked > 0 {
4708 risks.push(Note::new(
4709 "untracked",
4710 format!("{untracked} untracked file(s) would be lost"),
4711 ));
4712 }
4713
4714 let state = repo.state();
4716 if state != RepositoryState::Clean {
4717 risks.push(Note::new(
4718 "in-progress",
4719 format!("an in-progress {state:?} operation would be lost"),
4720 ));
4721 }
4722
4723 if repo.head_detached().unwrap_or(false) {
4727 let lost = unreachable_commit_count(&repo).unwrap_or(0);
4728 if lost > 0 {
4729 risks.push(Note::new(
4730 "unreachable-commits",
4731 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4732 ));
4733 }
4734 }
4735
4736 if let Some(ahead) = current_branch_ahead(&repo) {
4739 if ahead > 0 {
4740 info.push(Note::new(
4741 "unpushed",
4742 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4743 ));
4744 }
4745 }
4746
4747 Ok(GitSafety {
4748 is_main: false,
4749 removable: true,
4750 risks,
4751 info,
4752 })
4753}
4754
4755fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4762 let mut opts = StatusOptions::new();
4763 opts.include_untracked(true)
4764 .recurse_untracked_dirs(true)
4765 .include_ignored(false)
4766 .exclude_submodules(true);
4767 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4768 return (0, 0);
4769 };
4770 let tracked = Status::INDEX_NEW
4773 | Status::INDEX_MODIFIED
4774 | Status::INDEX_DELETED
4775 | Status::INDEX_RENAMED
4776 | Status::INDEX_TYPECHANGE
4777 | Status::WT_MODIFIED
4778 | Status::WT_DELETED
4779 | Status::WT_TYPECHANGE
4780 | Status::WT_RENAMED
4781 | Status::CONFLICTED;
4782 let mut dirty = 0;
4783 let mut untracked = 0;
4784 for entry in statuses.iter() {
4785 let s = entry.status();
4786 if s.contains(Status::WT_NEW) {
4787 untracked += 1;
4788 }
4789 if s.intersects(tracked) {
4790 dirty += 1;
4791 }
4792 }
4793 (dirty, untracked)
4794}
4795
4796fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4803 let head_oid = repo.head().ok()?.target()?;
4804 let mut walk = repo.revwalk().ok()?;
4805 walk.push(head_oid).ok()?;
4806 for reference in repo.references().ok()? {
4807 let Ok(reference) = reference else { continue };
4808 if matches!(reference.name(), Ok("HEAD")) {
4811 continue;
4812 }
4813 if let Some(oid) = reference.target() {
4814 let _ = walk.hide(oid);
4815 }
4816 }
4817 Some(walk.flatten().count())
4818}
4819
4820fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4824 let head = repo.head().ok()?;
4825 if !head.is_branch() {
4826 return None;
4827 }
4828 let branch = git2::Branch::wrap(head);
4829 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4830}
4831
4832fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4838 let names = main_repo.worktrees()?;
4839 names
4840 .iter()
4841 .flatten() .flatten() .find(|name| {
4844 main_repo
4845 .find_worktree(name)
4846 .is_ok_and(|wt| canonical(wt.path()) == target)
4847 })
4848 .map(str::to_string)
4849 .ok_or_else(|| {
4850 anyhow!(
4851 "worktree {} is not registered in {}",
4852 target.display(),
4853 main_repo.path().display()
4854 )
4855 })
4856}
4857
4858const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4867 Duration::from_millis(250),
4868 Duration::from_millis(500),
4869 Duration::from_secs(1),
4870 Duration::from_secs(1),
4871];
4872
4873fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4879 matches!(
4880 e.raw_os_error(),
4881 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4882 )
4883}
4884
4885fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4891 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4892}
4893
4894fn remove_dir_all_retrying_with(
4900 dir: &Path,
4901 backoff: &[Duration],
4902 mut remove: impl FnMut() -> std::io::Result<()>,
4903) -> Result<()> {
4904 let mut backoff = backoff.iter();
4905 loop {
4906 match remove() {
4907 Ok(()) => return Ok(()),
4908 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4909 Err(e) => {
4910 if is_transient_rmdir_error(&e) {
4911 if let Some(delay) = backoff.next() {
4912 std::thread::sleep(*delay);
4913 continue;
4914 }
4915 }
4916 return Err(e).with_context(|| {
4917 format!("failed to remove worktree directory {}", dir.display())
4918 });
4919 }
4920 }
4921 }
4922}
4923
4924fn is_orphaned_worktree(path: &Path) -> bool {
4933 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4936 return false;
4937 };
4938 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4939 return false;
4940 };
4941 let admin = Path::new(admin);
4942 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4944}
4945
4946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4951enum Removal {
4952 Pruned,
4954 AlreadyGone,
4956}
4957
4958fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4983 if !path.exists() {
4984 return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4985 }
4986 let repo = match Repository::open(path) {
4987 Ok(repo) => repo,
4988 Err(_) if is_orphaned_worktree(path) => {
4991 remove_dir_all_retrying(path)?;
4992 return Ok(Removal::Pruned);
4993 }
4994 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4995 };
4996 if !repo.is_worktree() {
4997 bail!(
4998 "refusing to delete the main working tree: {}",
4999 path.display()
5000 );
5001 }
5002 let commondir = canonical(repo.commondir());
5005 let main_root = commondir
5006 .parent()
5007 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5008 .to_path_buf();
5009 drop(repo);
5011 let main_repo = Repository::open(&main_root)
5012 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5013 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5014 let worktree = main_repo.find_worktree(&name)?;
5015
5016 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5018 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5019 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5020 }
5021
5022 remove_dir_all_retrying(path)?;
5025
5026 let mut opts = git2::WorktreePruneOptions::new();
5031 opts.valid(true).working_tree(false);
5032 worktree
5033 .prune(Some(&mut opts))
5034 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5035 Ok(Removal::Pruned)
5036}
5037
5038fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5054 let mut roots: Vec<PathBuf> = Vec::new();
5055 let mut push = |root: PathBuf| {
5056 if !roots.contains(&root) {
5057 roots.push(root);
5058 }
5059 };
5060 for ancestor in path.ancestors().skip(1) {
5062 if let Ok(repo) = Repository::open(ancestor) {
5063 if !repo.is_worktree() {
5064 if let Some(root) = canonical(repo.commondir()).parent() {
5065 push(root.to_path_buf());
5066 }
5067 }
5068 }
5069 }
5070 for folder in windows.iter().flat_map(|w| &w.folders) {
5071 if let Ok(repo) = Repository::discover(folder) {
5072 if let Some(root) = canonical(repo.commondir()).parent() {
5073 push(root.to_path_buf());
5074 }
5075 }
5076 }
5077 roots
5078}
5079
5080fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5089 let target = canonical(path);
5090 for root in candidate_main_repos {
5091 let Ok(main_repo) = Repository::open(root) else {
5092 continue;
5093 };
5094 if main_repo.is_worktree() {
5096 continue;
5097 }
5098 let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5100 continue;
5101 };
5102 let worktree = main_repo.find_worktree(&name)?;
5103 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5104 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5105 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5106 }
5107 let mut opts = git2::WorktreePruneOptions::new();
5108 opts.valid(true).working_tree(false);
5109 worktree.prune(Some(&mut opts)).with_context(|| {
5110 format!(
5111 "failed to prune orphaned worktree metadata for {}",
5112 path.display()
5113 )
5114 })?;
5115 return Ok(Removal::Pruned);
5116 }
5117 Ok(Removal::AlreadyGone)
5118}
5119
5120#[cfg(test)]
5121#[allow(clippy::unwrap_used, clippy::expect_used)]
5122mod tests {
5123 use super::*;
5124 use crate::test_support::shim::{
5125 retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5126 };
5127 use std::sync::MutexGuard;
5128
5129 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5130 json!({
5131 "key": key,
5132 "folders": [folder],
5133 "repo": repo,
5134 "title": format!("{key}-title"),
5135 "pid": 1234,
5136 })
5137 }
5138
5139 fn windows_of(payload: &Value) -> &Vec<Value> {
5141 payload
5142 .get("windows")
5143 .and_then(Value::as_array)
5144 .expect("windows array")
5145 }
5146
5147 #[tokio::test]
5148 async fn name_and_unknown_op() {
5149 let svc = WorktreesService::new();
5150 assert_eq!(svc.name(), "worktrees");
5151 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5152 }
5153
5154 #[tokio::test]
5155 async fn handle_routes_ops_and_shapes_payloads() {
5156 let svc = WorktreesService::new();
5157 let payload = svc.handle("list", Value::Null).await.unwrap();
5159 assert_eq!(payload, json!({ "windows": [] }));
5160
5161 let reply = svc
5163 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5164 .await
5165 .unwrap();
5166 assert_eq!(reply, json!({ "ok": true }));
5167 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5168 assert_eq!(windows.len(), 1);
5169 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5170 assert!(windows[0].get("last_seen").is_some());
5171
5172 let known = svc
5174 .handle("heartbeat", json!({ "key": "w1" }))
5175 .await
5176 .unwrap();
5177 assert_eq!(known, json!({ "known": true }));
5178 let unknown = svc
5179 .handle("heartbeat", json!({ "key": "nope" }))
5180 .await
5181 .unwrap();
5182 assert_eq!(unknown, json!({ "known": false }));
5183
5184 let reloaded = svc
5186 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5187 .await
5188 .unwrap();
5189 assert_eq!(
5190 reloaded,
5191 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5192 );
5193 assert!(svc.registry.take_reload_pending("w1"));
5194
5195 let gone = svc
5197 .handle("unregister", json!({ "key": "w1" }))
5198 .await
5199 .unwrap();
5200 assert_eq!(gone, json!({ "removed": true }));
5201 let again = svc
5202 .handle("unregister", json!({ "key": "w1" }))
5203 .await
5204 .unwrap();
5205 assert_eq!(again, json!({ "removed": false }));
5206 }
5207
5208 #[derive(Clone)]
5220 struct StubBackend {
5221 trusted: bool,
5222 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5225 writes: Arc<Mutex<Vec<geometry::Frame>>>,
5227 }
5228
5229 impl StubBackend {
5230 fn new(trusted: bool) -> Self {
5233 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5234 title: title.to_string(),
5235 frame: geometry::Frame {
5236 x,
5237 y: 0.0,
5238 width,
5239 height: 600.0,
5240 },
5241 minimized: false,
5242 fullscreen: false,
5243 standard: true,
5244 focused: false,
5245 };
5246 Self {
5247 trusted,
5248 windows: Arc::new(Mutex::new(vec![
5249 window("plan.md — ref-tree", 0.0, 800.0),
5250 window("main.rs — other-tree", 900.0, 500.0),
5251 ])),
5252 writes: Arc::new(Mutex::new(Vec::new())),
5253 }
5254 }
5255
5256 fn writes(&self) -> Vec<geometry::Frame> {
5257 self.writes
5258 .lock()
5259 .unwrap_or_else(PoisonError::into_inner)
5260 .clone()
5261 }
5262
5263 fn frame_of(&self, index: usize) -> geometry::Frame {
5265 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5266 }
5267
5268 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5270 let clone = self.clone();
5271 move || clone
5272 }
5273 }
5274
5275 impl geometry::WindowBackend for StubBackend {
5276 fn trusted(&self) -> bool {
5277 self.trusted
5278 }
5279
5280 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5281 pids.iter()
5282 .filter(|p| **p == 11 || **p == 12)
5283 .map(|p| (*p, 900))
5284 .collect()
5285 }
5286
5287 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5288 if app_pid != 900 {
5289 return Ok(Vec::new());
5290 }
5291 Ok(self
5292 .windows
5293 .lock()
5294 .unwrap_or_else(PoisonError::into_inner)
5295 .clone())
5296 }
5297
5298 fn set_frame(
5299 &self,
5300 id: geometry::WindowId,
5301 frame: geometry::Frame,
5302 ) -> Result<geometry::Frame, String> {
5303 self.writes
5304 .lock()
5305 .unwrap_or_else(PoisonError::into_inner)
5306 .push(frame);
5307 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5308 let window = windows
5309 .get_mut(id.index)
5310 .ok_or_else(|| format!("no window at index {}", id.index))?;
5311 window.frame = frame;
5312 Ok(frame)
5313 }
5314 }
5315
5316 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5319 svc.registry.register(
5320 serde_json::from_value(json!({
5321 "key": key,
5322 "folders": [format!("/tmp/{key}")],
5323 "title": title,
5324 "pid": pid,
5325 }))
5326 .expect("valid register payload"),
5327 );
5328 }
5329
5330 #[tokio::test]
5331 async fn reposition_requires_a_resolvable_reference() {
5332 let svc = WorktreesService::new();
5333 assert!(svc.handle("reposition", json!({})).await.is_err());
5336 assert!(svc
5337 .handle("reposition", json!({ "reference_key": " " }))
5338 .await
5339 .is_err());
5340 assert!(svc
5341 .handle("reposition", json!({ "reference_key": "ghost" }))
5342 .await
5343 .is_err());
5344 }
5345
5346 #[tokio::test]
5347 async fn reposition_moves_targets_and_records_an_undo() {
5348 let svc = WorktreesService::new();
5349 register_window(&svc, "ref", "ref-tree", 11);
5350 register_window(&svc, "other", "other-tree", 12);
5351 let backend = StubBackend::new(true);
5352
5353 let reply = svc
5354 .reposition_with(
5355 serde_json::from_value(json!({
5356 "reference_key": "ref",
5357 "target_keys": ["other"],
5358 }))
5359 .unwrap(),
5360 backend.factory(),
5361 )
5362 .await
5363 .unwrap();
5364
5365 assert_eq!(reply["trusted"], json!(true));
5366 assert_eq!(reply["moved"], json!(1));
5367 assert_eq!(reply["skipped"], json!(0));
5368 assert_eq!(reply["undoable"], json!(true));
5369 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5370 assert_eq!(reply["results"][0]["key"], json!("other"));
5371 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5372 assert_eq!(backend.writes().len(), 1);
5374 assert_eq!(
5375 backend.writes()[0],
5376 backend.frame_of(0),
5377 "wrote the reference window's own frame"
5378 );
5379 assert_eq!(
5380 backend.frame_of(1),
5381 backend.frame_of(0),
5382 "the target now occupies the reference's frame"
5383 );
5384
5385 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5389 assert_eq!(undone["moved"], json!(1));
5390 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5391 assert!(undone.get("reference").is_none(), "undo has no reference");
5392 assert_eq!(
5393 backend.frame_of(1),
5394 geometry::Frame {
5395 x: 900.0,
5396 y: 0.0,
5397 width: 500.0,
5398 height: 600.0,
5399 },
5400 "restored to exactly the pre-move frame"
5401 );
5402
5403 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5404 assert_eq!(
5405 again,
5406 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5407 );
5408 }
5409
5410 #[tokio::test]
5411 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5412 let svc = WorktreesService::new();
5413 register_window(&svc, "ref", "ref-tree", 11);
5414 register_window(&svc, "other", "other-tree", 12);
5415 let backend = StubBackend::new(true);
5416
5417 let reply = svc
5418 .reposition_with(
5419 serde_json::from_value(json!({
5420 "reference_key": "ref",
5421 "target_keys": ["other"],
5422 "check": true,
5423 }))
5424 .unwrap(),
5425 backend.factory(),
5426 )
5427 .await
5428 .unwrap();
5429
5430 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5431 assert!(
5432 reply.get("undoable").is_none(),
5433 "a dry run leaves nothing to undo"
5434 );
5435 assert!(
5436 backend.writes().is_empty(),
5437 "a dry run must not touch a window"
5438 );
5439 }
5440
5441 #[tokio::test]
5442 async fn reposition_reports_a_missing_permission_as_data() {
5443 let svc = WorktreesService::new();
5444 register_window(&svc, "ref", "ref-tree", 11);
5445 register_window(&svc, "other", "other-tree", 12);
5446 let backend = StubBackend::new(false);
5447
5448 let reply = svc
5449 .reposition_with(
5450 serde_json::from_value(json!({
5451 "reference_key": "ref",
5452 "target_keys": ["other"],
5453 }))
5454 .unwrap(),
5455 backend.factory(),
5456 )
5457 .await
5458 .unwrap();
5459
5460 assert_eq!(reply["trusted"], json!(false));
5463 assert_eq!(reply["moved"], json!(0));
5464 assert!(backend.writes().is_empty());
5465 }
5466
5467 #[tokio::test]
5468 async fn a_stale_target_key_is_skipped_not_fatal() {
5469 let svc = WorktreesService::new();
5470 register_window(&svc, "ref", "ref-tree", 11);
5471 register_window(&svc, "other", "other-tree", 12);
5472 let backend = StubBackend::new(true);
5473
5474 let reply = svc
5475 .reposition_with(
5476 serde_json::from_value(json!({
5477 "reference_key": "ref",
5478 "target_keys": ["closed-since", "ref", "other"],
5481 }))
5482 .unwrap(),
5483 backend.factory(),
5484 )
5485 .await
5486 .unwrap();
5487
5488 let outcomes: Vec<&str> = reply["results"]
5489 .as_array()
5490 .unwrap()
5491 .iter()
5492 .map(|r| r["outcome"].as_str().unwrap())
5493 .collect();
5494 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5495 assert_eq!(reply["moved"], json!(1));
5496 assert_eq!(reply["skipped"], json!(2));
5497 }
5498
5499 #[tokio::test]
5500 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5501 let svc = WorktreesService::new();
5502 register_window(&svc, "ref", "twin", 11);
5505 register_window(&svc, "other", "other-tree", 12);
5506 let backend = StubBackend::new(true);
5509 {
5510 let mut windows = backend
5511 .windows
5512 .lock()
5513 .unwrap_or_else(PoisonError::into_inner);
5514 windows[0].title = "a.rs — twin".to_string();
5515 windows[1].title = "b.rs — twin".to_string();
5516 }
5517
5518 let reply = svc
5519 .reposition_with(
5520 serde_json::from_value(json!({
5521 "reference_key": "ref",
5522 "target_keys": ["other"],
5523 }))
5524 .unwrap(),
5525 backend.factory(),
5526 )
5527 .await
5528 .unwrap();
5529
5530 assert_eq!(reply["trusted"], json!(true));
5531 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5532 assert!(
5533 reply["blocked"]["detail"]
5534 .as_str()
5535 .is_some_and(|d| d.contains("twin")),
5536 "the reason should name the ambiguous title: {reply}"
5537 );
5538 assert_eq!(reply["results"], json!([]), "no target is attempted");
5539 assert!(reply.get("undoable").is_none());
5540 assert!(backend.writes().is_empty());
5541
5542 let undone = svc
5544 .reposition_undo_with(StubBackend::new(true).factory())
5545 .await
5546 .unwrap();
5547 assert_eq!(undone["moved"], json!(0));
5548 }
5549
5550 #[tokio::test]
5551 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5552 let svc = WorktreesService::new();
5553 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5554 assert_eq!(reply["moved"], json!(0));
5555 assert_eq!(reply["results"], json!([]));
5556 }
5557
5558 #[test]
5559 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5560 let empty = geometry::RepositionReport {
5561 trusted: true,
5562 blocked: None,
5563 reference: None,
5564 results: Vec::new(),
5565 undo: Vec::new(),
5566 };
5567 assert_eq!(outcome_kinds(&empty), "-");
5568 }
5569
5570 #[tokio::test]
5571 async fn handle_rejects_missing_or_empty_key() {
5572 let svc = WorktreesService::new();
5573 assert!(svc.handle("register", json!({})).await.is_err());
5575 assert!(svc
5576 .handle("register", json!({ "key": " " }))
5577 .await
5578 .is_err());
5579 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5581 assert!(svc.handle("unregister", json!({})).await.is_err());
5582 }
5583
5584 #[test]
5585 fn display_name_prefers_repo_then_folder_basename() {
5586 let base = WindowEntry {
5587 key: "k".to_string(),
5588 folders: vec![PathBuf::from("/home/me/project")],
5589 repo: Some("my-repo".to_string()),
5590 title: None,
5591 pid: None,
5592 last_seen: Utc::now(),
5593 };
5594 assert_eq!(display_name(&base), "my-repo");
5595
5596 let no_repo = WindowEntry {
5597 repo: None,
5598 ..base.clone()
5599 };
5600 assert_eq!(display_name(&no_repo), "project");
5601
5602 let nothing = WindowEntry {
5603 repo: None,
5604 folders: vec![],
5605 ..base.clone()
5606 };
5607 assert_eq!(display_name(¬hing), "(no folder)");
5608
5609 let rootish = WindowEntry {
5612 repo: None,
5613 folders: vec![PathBuf::from("/")],
5614 ..base
5615 };
5616 assert_eq!(display_name(&rootish), "/");
5617 }
5618
5619 #[test]
5620 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5621 let now = Utc::now();
5622 let entries = vec![
5623 WindowEntry {
5628 key: "k2".to_string(),
5629 folders: vec![],
5630 repo: Some("solo".to_string()),
5631 title: Some("solo".to_string()),
5632 pid: None,
5633 last_seen: now,
5634 },
5635 WindowEntry {
5638 key: "k1".to_string(),
5639 folders: vec![PathBuf::from("/tmp/a")],
5640 repo: Some("repo".to_string()),
5641 title: Some("a branch".to_string()),
5642 pid: None,
5643 last_seen: now,
5644 },
5645 ];
5646 let items = window_menu_items(&entries);
5647 assert_eq!(items.len(), 2);
5649 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5650
5651 let action = items
5654 .iter()
5655 .find_map(|i| match i {
5656 MenuItem::Action(a) => Some(a),
5657 _ => None,
5658 })
5659 .expect("a focus action");
5660 assert_eq!(action.id, "focus:k1");
5661 assert_eq!(action.label, "repo · a branch");
5662
5663 let labels: Vec<&str> = items
5665 .iter()
5666 .filter_map(|i| match i {
5667 MenuItem::Label(t) => Some(t.as_str()),
5668 _ => None,
5669 })
5670 .collect();
5671 assert_eq!(labels, vec!["solo"]);
5672 }
5673
5674 #[tokio::test]
5675 async fn menu_and_status_shapes() {
5676 let svc = WorktreesService::new();
5677 let menu = svc.menu();
5679 assert_eq!(menu.title, "Worktrees");
5680 assert!(matches!(
5681 menu.items.first(),
5682 Some(MenuItem::Label(text)) if text == "No open windows"
5683 ));
5684 let status = svc.status().await;
5685 assert_eq!(status.name, "worktrees");
5686 assert!(status.healthy);
5687 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5688
5689 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5692 .await
5693 .unwrap();
5694 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5695 .await
5696 .unwrap();
5697 svc.handle(
5698 "register",
5699 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5700 )
5701 .await
5702 .unwrap();
5703 let status = svc.status().await;
5704 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5705
5706 let menu = svc.menu();
5707 assert_eq!(menu.items.len(), 3);
5709 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5710 let action_ids: Vec<&str> = menu
5711 .items
5712 .iter()
5713 .filter_map(|i| match i {
5714 MenuItem::Action(a) => Some(a.id.as_str()),
5715 _ => None,
5716 })
5717 .collect();
5718 assert!(action_ids.contains(&"focus:w1"));
5721 assert!(action_ids.contains(&"focus:w2"));
5722 assert!(!action_ids.contains(&"focus:w3"));
5723 }
5724
5725 #[test]
5726 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5727 let svc = WorktreesService::new();
5730 svc.start_menu_refresh();
5731 assert!(svc.refresh.lock().unwrap().is_none());
5732 }
5733
5734 #[tokio::test]
5735 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5736 let svc = WorktreesService::new();
5737 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5738 .await
5739 .unwrap();
5740 assert!(svc.menu_cache.lock().unwrap().is_none());
5742
5743 svc.start_menu_refresh();
5744 svc.start_menu_refresh();
5746
5747 let mut filled = false;
5749 for _ in 0..100 {
5750 if svc.menu_cache.lock().unwrap().is_some() {
5751 filled = true;
5752 break;
5753 }
5754 tokio::time::sleep(Duration::from_millis(10)).await;
5755 }
5756 assert!(filled, "background refresh should populate the menu cache");
5757
5758 let menu = svc.menu();
5760 assert_eq!(menu.title, "Worktrees");
5761 assert!(menu
5762 .items
5763 .iter()
5764 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5765
5766 svc.shutdown().await;
5768 assert!(svc.refresh.lock().unwrap().is_none());
5769 }
5770
5771 #[tokio::test]
5772 async fn default_constructs_an_empty_service() {
5773 let svc = WorktreesService::default();
5774 let payload = svc.handle("list", Value::Null).await.unwrap();
5775 assert_eq!(payload, json!({ "windows": [] }));
5776 }
5777
5778 #[tokio::test]
5781 async fn subscribe_streams_only_for_the_subscribe_op() {
5782 let svc = WorktreesService::new();
5783 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5787 assert!(svc.subscribe("list", &Value::Null).is_none());
5788 assert!(svc.subscribe("register", &Value::Null).is_none());
5789 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5790 }
5791
5792 #[tokio::test]
5793 async fn subscribe_snapshot_matches_the_tree_op() {
5794 let dir = tempfile::tempdir().unwrap();
5795 let repo = init_repo(dir.path());
5796 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5797 repo.set_head("refs/heads/main").unwrap();
5798
5799 let svc = WorktreesService::new();
5800 let stream = svc
5801 .subscribe("subscribe", &Value::Null)
5802 .expect("subscribe stream");
5803 assert_eq!(
5806 stream.snapshot().await,
5807 json!({ "repos": [], "show_closed": true })
5808 );
5809
5810 svc.handle(
5813 "register",
5814 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5815 )
5816 .await
5817 .unwrap();
5818 let snap = stream.snapshot().await;
5819 let tree = svc.handle("tree", Value::Null).await.unwrap();
5820 assert_eq!(snap, tree);
5821 let repos = snap["repos"].as_array().expect("repos array");
5822 assert_eq!(repos.len(), 1);
5823 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5824 }
5825
5826 #[tokio::test]
5827 async fn subscribe_changed_wakes_on_register() {
5828 let svc = WorktreesService::new();
5829 let mut stream = svc
5830 .subscribe("subscribe", &Value::Null)
5831 .expect("subscribe stream");
5832 tokio::select! {
5834 () = stream.changed() => panic!("changed resolved with no registry change"),
5835 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5836 }
5837 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5839 .await
5840 .unwrap();
5841 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5842 .await
5843 .expect("changed should resolve after a register");
5844 }
5845
5846 #[tokio::test]
5849 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5850 let reg = Arc::new(WorktreesRegistry::new());
5851 let cache = TreeSnapshotCache::with_ttl(
5853 reg,
5854 Arc::new(PrStatusCache::new()),
5855 Duration::from_secs(60),
5856 );
5857 let first = cache.snapshot().await;
5859 assert_eq!(cache.compute_count(), 1);
5860 let second = cache.snapshot().await;
5863 assert_eq!(
5864 cache.compute_count(),
5865 1,
5866 "an unchanged read must not rebuild"
5867 );
5868 assert_eq!(first, second);
5869 }
5870
5871 #[tokio::test]
5872 async fn tree_cache_single_flights_a_read_burst() {
5873 let reg = Arc::new(WorktreesRegistry::new());
5874 let cache = Arc::new(TreeSnapshotCache::with_ttl(
5875 reg,
5876 Arc::new(PrStatusCache::new()),
5877 Duration::from_secs(60),
5878 ));
5879 let mut handles = Vec::new();
5883 for _ in 0..16 {
5884 let cache = cache.clone();
5885 handles.push(tokio::spawn(async move { cache.snapshot().await }));
5886 }
5887 let mut results = Vec::new();
5888 for handle in handles {
5889 results.push(handle.await.unwrap());
5890 }
5891 assert_eq!(
5892 cache.compute_count(),
5893 1,
5894 "a concurrent read burst must build the tree once"
5895 );
5896 assert!(
5897 results.windows(2).all(|w| w[0] == w[1]),
5898 "every reader must observe the identical snapshot"
5899 );
5900 }
5901
5902 #[tokio::test]
5903 async fn tree_cache_rebuilds_on_registry_change() {
5904 let reg = Arc::new(WorktreesRegistry::new());
5905 let cache = TreeSnapshotCache::with_ttl(
5906 reg.clone(),
5907 Arc::new(PrStatusCache::new()),
5908 Duration::from_secs(60),
5909 );
5910 cache.snapshot().await;
5911 assert_eq!(cache.compute_count(), 1);
5912 assert!(reg.set_show_closed(false));
5916 cache.snapshot().await;
5917 assert_eq!(
5918 cache.compute_count(),
5919 2,
5920 "a generation bump must force a rebuild"
5921 );
5922 }
5923
5924 #[tokio::test]
5925 async fn tree_cache_rebuilds_after_ttl_expiry() {
5926 let reg = Arc::new(WorktreesRegistry::new());
5927 let cache =
5930 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5931 cache.snapshot().await;
5932 cache.snapshot().await;
5933 assert_eq!(
5934 cache.compute_count(),
5935 2,
5936 "an expired TTL must force a rebuild each read"
5937 );
5938 }
5939
5940 #[tokio::test]
5941 async fn subscribe_streams_share_one_build_per_generation() {
5942 let svc = WorktreesService::new();
5943 let s1 = svc
5944 .subscribe("subscribe", &Value::Null)
5945 .expect("subscribe stream");
5946 let s2 = svc
5947 .subscribe("subscribe", &Value::Null)
5948 .expect("subscribe stream");
5949 let a = s1.snapshot().await;
5952 let b = s2.snapshot().await;
5953 assert_eq!(a, b);
5954 assert_eq!(
5955 svc.tree_cache.compute_count(),
5956 1,
5957 "N streams on one generation must share a single build"
5958 );
5959 }
5960
5961 #[tokio::test]
5964 async fn set_show_closed_toggles_the_snapshot_field() {
5965 let svc = WorktreesService::new();
5966 assert_eq!(
5968 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5969 json!(true)
5970 );
5971 let reply = svc
5973 .handle("set-show-closed", json!({ "show_closed": false }))
5974 .await
5975 .unwrap();
5976 assert_eq!(reply, json!({ "ok": true }));
5977 assert_eq!(
5978 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5979 json!(false)
5980 );
5981 }
5982
5983 #[tokio::test]
5984 async fn set_show_closed_rejects_a_non_boolean_payload() {
5985 let svc = WorktreesService::new();
5986 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5987 assert!(svc
5988 .handle("set-show-closed", json!({ "show_closed": "yes" }))
5989 .await
5990 .is_err());
5991 }
5992
5993 #[tokio::test]
5994 async fn set_show_closed_wakes_the_subscription() {
5995 let svc = WorktreesService::new();
5996 let mut stream = svc
5997 .subscribe("subscribe", &Value::Null)
5998 .expect("subscribe stream");
5999 svc.handle("set-show-closed", json!({ "show_closed": false }))
6001 .await
6002 .unwrap();
6003 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6004 .await
6005 .expect("changed should resolve after a toggle flip");
6006 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6008 }
6009
6010 #[tokio::test]
6011 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6012 let dir = tempfile::tempdir().unwrap();
6015 github_repo(dir.path());
6016 let svc = WorktreesService::new();
6017 svc.handle(
6018 "register",
6019 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6020 )
6021 .await
6022 .unwrap();
6023
6024 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6025 assert!(
6026 repo.get("polling_enabled").is_none(),
6027 "default off omits the flag: {repo:?}"
6028 );
6029
6030 let reply = svc
6031 .handle(
6032 "set-polling",
6033 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6034 )
6035 .await
6036 .unwrap();
6037 assert_eq!(reply, json!({ "ok": true }));
6038 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6039 assert_eq!(repo["polling_enabled"], json!(true));
6040
6041 svc.handle(
6042 "set-polling",
6043 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6044 )
6045 .await
6046 .unwrap();
6047 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6048 assert!(repo.get("polling_enabled").is_none());
6049 }
6050
6051 #[tokio::test]
6052 async fn set_polling_rejects_missing_or_empty_fields() {
6053 let svc = WorktreesService::new();
6054 assert!(svc
6056 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6057 .await
6058 .is_err());
6059 assert!(svc
6061 .handle("set-polling", json!({ "enabled": true }))
6062 .await
6063 .is_err());
6064 assert!(svc
6066 .handle(
6067 "set-polling",
6068 json!({ "owner": " ", "name": "n", "enabled": true })
6069 )
6070 .await
6071 .is_err());
6072 }
6073
6074 #[tokio::test]
6075 async fn set_polling_wakes_the_subscription() {
6076 let dir = tempfile::tempdir().unwrap();
6077 github_repo(dir.path());
6078 let svc = WorktreesService::new();
6079 svc.handle(
6080 "register",
6081 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6082 )
6083 .await
6084 .unwrap();
6085 let mut stream = svc
6086 .subscribe("subscribe", &Value::Null)
6087 .expect("subscribe stream");
6088 svc.handle(
6089 "set-polling",
6090 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6091 )
6092 .await
6093 .unwrap();
6094 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6095 .await
6096 .expect("changed should resolve after enabling a repo");
6097 let repo = repos_of(&stream.snapshot().await)[0].clone();
6098 assert_eq!(repo["polling_enabled"], json!(true));
6099 }
6100
6101 #[tokio::test]
6102 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6103 let dir = tempfile::tempdir().unwrap();
6107 let repo = github_repo(dir.path());
6108 let head = repo.head().unwrap().target().unwrap().to_string();
6109 let svc = WorktreesService::new();
6110 svc.handle(
6111 "register",
6112 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6113 )
6114 .await
6115 .unwrap();
6116 svc.registry.set_polling("rust-works", "omni-dev", true);
6117
6118 let mut badges = HashMap::new();
6119 badges.insert(
6120 PrTarget {
6121 owner: "rust-works".into(),
6122 name: "omni-dev".into(),
6123 branch: "main".into(),
6124 },
6125 pr(pending_badge(7, &head)),
6126 );
6127 svc.pr_cache.replace(badges);
6128
6129 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6130 assert_eq!(wt["pr"]["number"], json!(7));
6131
6132 svc.handle(
6133 "set-polling",
6134 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6135 )
6136 .await
6137 .unwrap();
6138 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6139 assert!(
6140 wt.get("pr").is_none(),
6141 "a disabled repo must carry no badge: {wt:?}"
6142 );
6143 }
6144
6145 #[tokio::test]
6146 async fn an_expired_lease_drops_the_flag_and_badges() {
6147 let dir = tempfile::tempdir().unwrap();
6151 let repo = github_repo(dir.path());
6152 let head = repo.head().unwrap().target().unwrap().to_string();
6153 let svc = WorktreesService::new();
6154 svc.handle(
6155 "register",
6156 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6157 )
6158 .await
6159 .unwrap();
6160 svc.registry.set_polling("rust-works", "omni-dev", true);
6161 let mut badges = HashMap::new();
6162 badges.insert(
6163 PrTarget {
6164 owner: "rust-works".into(),
6165 name: "omni-dev".into(),
6166 branch: "main".into(),
6167 },
6168 pr(pending_badge(7, &head)),
6169 );
6170 svc.pr_cache.replace(badges);
6171
6172 let snap = svc.handle("tree", Value::Null).await.unwrap();
6174 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6175 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6176 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6177
6178 svc.registry.set_polling_expiry(
6180 "rust-works",
6181 "omni-dev",
6182 Utc::now() - chrono::Duration::minutes(1),
6183 );
6184
6185 let snap = svc.handle("tree", Value::Null).await.unwrap();
6186 let repo0 = &repos_of(&snap)[0];
6187 assert!(
6188 repo0.get("polling_enabled").is_none(),
6189 "expired lease drops the flag: {repo0:?}"
6190 );
6191 assert!(
6192 repo0["worktrees"][0].get("pr").is_none(),
6193 "expired lease drops the badge"
6194 );
6195 assert!(
6196 pr_targets_from_snapshot(&snap).is_empty(),
6197 "the poller no longer watches an expired repo"
6198 );
6199 }
6200
6201 #[tokio::test]
6202 async fn polling_prefs_persist_across_reloads_with_0600() {
6203 let dir = tempfile::tempdir().unwrap();
6206 let prefs = dir.path().join("worktrees-polling.json");
6207
6208 let svc = WorktreesService::new();
6209 svc.load_polling_prefs(prefs.clone());
6210 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6211 svc.handle(
6212 "set-polling",
6213 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6214 )
6215 .await
6216 .unwrap();
6217 assert!(prefs.exists());
6218 #[cfg(unix)]
6219 {
6220 use std::os::unix::fs::PermissionsExt;
6221 assert_eq!(
6222 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6223 0o600
6224 );
6225 }
6226
6227 let svc2 = WorktreesService::new();
6229 svc2.load_polling_prefs(prefs.clone());
6230 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6231
6232 svc2.handle(
6234 "set-polling",
6235 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6236 )
6237 .await
6238 .unwrap();
6239 let svc3 = WorktreesService::new();
6240 svc3.load_polling_prefs(prefs);
6241 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6242 }
6243
6244 #[test]
6245 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6246 let dir = tempfile::tempdir().unwrap();
6251
6252 let corrupt = dir.path().join("worktrees-polling.json");
6254 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6255 let svc = WorktreesService::new();
6256 svc.load_polling_prefs(corrupt);
6257 assert!(svc.registry.enabled_polling_repos().is_empty());
6258
6259 let as_dir = dir.path().join("is-a-directory");
6261 std::fs::create_dir(&as_dir).unwrap();
6262 let svc2 = WorktreesService::new();
6263 svc2.load_polling_prefs(as_dir);
6264 assert!(svc2.registry.enabled_polling_repos().is_empty());
6265 }
6266
6267 #[tokio::test]
6268 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6269 let dir = tempfile::tempdir().unwrap();
6272 github_repo(dir.path());
6273 let bin_dir = tempfile::tempdir().unwrap();
6274 let marker = bin_dir.path().join("spawned");
6275 let fake = bin_dir.path().join("fake-gh");
6276 std::fs::write(
6277 &fake,
6278 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6279 )
6280 .unwrap();
6281 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6282 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6283 std::fs::set_permissions(&fake, perms).unwrap();
6284
6285 let svc = WorktreesService::new();
6286 svc.handle(
6287 "register",
6288 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6289 )
6290 .await
6291 .unwrap();
6292 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6294 tokio::time::sleep(Duration::from_millis(200)).await;
6295 svc.shutdown().await;
6296 assert!(
6297 !marker.exists(),
6298 "a registered-but-not-enabled repo must drive zero gh"
6299 );
6300 }
6301
6302 #[tokio::test]
6303 async fn menu_action_rejects_unknown_and_missing_window() {
6304 let svc = WorktreesService::new();
6305 assert!(svc.menu_action("bogus").await.is_err());
6306 assert!(svc.menu_action("focus:nope").await.is_err());
6308 svc.shutdown().await;
6309 }
6310
6311 struct VscodeBinGuard(Option<std::ffi::OsString>);
6318 impl Drop for VscodeBinGuard {
6319 fn drop(&mut self) {
6320 match self.0.take() {
6321 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6322 None => std::env::remove_var(VSCODE_BIN_ENV),
6323 }
6324 }
6325 }
6326
6327 #[tokio::test]
6328 async fn menu_action_focus_resolves_folder_and_spawns() {
6329 let dir = tempfile::tempdir().unwrap();
6330 let svc = WorktreesService::new();
6331 svc.handle(
6332 "register",
6333 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6334 )
6335 .await
6336 .unwrap();
6337
6338 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6341 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6342 svc.menu_action("focus:w1").await.unwrap();
6343 }
6344
6345 #[tokio::test]
6346 async fn open_rejects_missing_relative_or_nonexistent_path() {
6347 let svc = WorktreesService::new();
6348 assert!(svc.handle("open", json!({})).await.is_err());
6350 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6351 assert!(svc
6354 .handle("open", json!({ "path": "relative/dir" }))
6355 .await
6356 .is_err());
6357 assert!(svc
6358 .handle("open", json!({ "path": "-flag" }))
6359 .await
6360 .is_err());
6361 assert!(svc
6364 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6365 .await
6366 .is_err());
6367 svc.shutdown().await;
6368 }
6369
6370 #[tokio::test]
6371 async fn open_focuses_an_existing_absolute_dir() {
6372 let dir = tempfile::tempdir().unwrap();
6373 let svc = WorktreesService::new();
6374 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6379 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6380 let reply = svc
6381 .handle("open", json!({ "path": dir.path() }))
6382 .await
6383 .unwrap();
6384 assert_eq!(reply, json!({ "ok": true }));
6385 svc.shutdown().await;
6386 }
6387
6388 #[test]
6389 fn focus_window_with_validates_folder_then_spawns() {
6390 let dir = tempfile::tempdir().unwrap();
6391 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6393 assert!(
6394 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6395 );
6396 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6398 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6400 }
6401
6402 #[test]
6403 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6404 assert_eq!(
6406 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6407 PathBuf::from("/custom/code")
6408 );
6409 let existing = tempfile::NamedTempFile::new().unwrap();
6411 let existing_path = existing.path().to_str().unwrap();
6412 assert_eq!(
6413 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6414 PathBuf::from(existing_path)
6415 );
6416 assert_eq!(
6418 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6419 PathBuf::from("code")
6420 );
6421 let _ = resolve_code_binary();
6423 }
6424
6425 fn init_repo(dir: &Path) -> Repository {
6430 let repo = Repository::init(dir).unwrap();
6431 let mut cfg = repo.config().unwrap();
6432 cfg.set_str("user.name", "Test").unwrap();
6433 cfg.set_str("user.email", "test@example.com").unwrap();
6434 repo
6435 }
6436
6437 fn empty_commit(
6440 repo: &Repository,
6441 refname: Option<&str>,
6442 parents: &[&git2::Commit<'_>],
6443 msg: &str,
6444 ) -> git2::Oid {
6445 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6446 let tree = repo
6447 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6448 .unwrap();
6449 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6450 .unwrap()
6451 }
6452
6453 fn commit_file(
6458 repo: &Repository,
6459 refname: &str,
6460 name: &str,
6461 content: &[u8],
6462 msg: &str,
6463 ) -> git2::Oid {
6464 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6465 let blob = repo.blob(content).unwrap();
6466 let mut builder = repo.treebuilder(None).unwrap();
6467 builder.insert(name, blob, 0o100_644).unwrap();
6468 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6469 let parent = repo
6470 .refname_to_id(refname)
6471 .ok()
6472 .and_then(|oid| repo.find_commit(oid).ok());
6473 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6474 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6475 .unwrap()
6476 }
6477
6478 fn diverging_repo(dir: &Path) -> Repository {
6481 let repo = init_repo(dir);
6482 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6484 let a_commit = repo.find_commit(a).unwrap();
6485 let c = empty_commit(&repo, None, &[&a_commit], "C");
6487 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6488 .unwrap();
6489 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6491 drop(a_commit);
6493 repo.set_head("refs/heads/main").unwrap();
6494 let mut cfg = repo.config().unwrap();
6496 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6497 .unwrap();
6498 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6499 .unwrap();
6500 cfg.set_str("branch.main.remote", "origin").unwrap();
6501 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6502 repo
6503 }
6504
6505 fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6511 let repo = init_repo(dir);
6512 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6513 let a_commit = repo.find_commit(a).unwrap();
6514 let c = empty_commit(&repo, None, &[&a_commit], "C");
6515 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6516 .unwrap();
6517 drop(a_commit);
6518 repo.set_head("refs/heads/main").unwrap();
6519 repo
6520 }
6521
6522 #[test]
6523 fn git_status_reads_branch_and_ahead_behind() {
6524 let dir = tempfile::tempdir().unwrap();
6525 let _repo = diverging_repo(dir.path());
6526 let status = git_status(dir.path());
6527 assert_eq!(status.branch.as_deref(), Some("main"));
6528 assert_eq!(status.ahead, Some(1));
6529 assert_eq!(status.behind, Some(1));
6530 assert_eq!(
6532 status.main_repo.as_deref(),
6533 dir.path().file_name().and_then(|n| n.to_str())
6534 );
6535 assert!(!status.is_worktree);
6536 }
6537
6538 #[test]
6539 fn git_status_empty_repo_is_unborn() {
6540 let dir = tempfile::tempdir().unwrap();
6544 init_repo(dir.path());
6545 let status = git_status(dir.path());
6546 assert_eq!(status.branch, None);
6547 assert_eq!(status.head_sha, None);
6549 assert_eq!(status.ahead, None);
6550 assert_eq!(status.behind, None);
6551 assert_eq!(
6552 status.main_repo.as_deref(),
6553 dir.path().file_name().and_then(|n| n.to_str())
6554 );
6555 assert!(!status.is_worktree);
6556 }
6557
6558 #[test]
6559 fn git_status_no_upstream_reports_branch_only() {
6560 let dir = tempfile::tempdir().unwrap();
6561 let repo = init_repo(dir.path());
6562 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6563 repo.set_head("refs/heads/main").unwrap();
6564 let status = git_status(dir.path());
6565 assert_eq!(status.branch.as_deref(), Some("main"));
6566 assert_eq!(status.ahead, None);
6568 assert_eq!(status.behind, None);
6569 assert_eq!(status.upstream_sha, None);
6572 }
6573
6574 #[test]
6575 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6576 let plain = tempfile::tempdir().unwrap();
6578 assert_eq!(git_status(plain.path()), GitStatus::default());
6579
6580 let dir = tempfile::tempdir().unwrap();
6583 let repo = init_repo(dir.path());
6584 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6585 repo.set_head_detached(a).unwrap();
6586 let status = git_status(dir.path());
6587 assert_eq!(status.branch, None);
6588 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6591 assert_eq!(status.ahead, None);
6592 assert_eq!(status.behind, None);
6593 assert_eq!(status.upstream_sha, None);
6596 assert_eq!(
6597 status.main_repo.as_deref(),
6598 dir.path().file_name().and_then(|n| n.to_str())
6599 );
6600 assert!(!status.is_worktree);
6601 }
6602
6603 #[test]
6606 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6607 let dir = tempfile::tempdir().unwrap();
6611 let repo = diverging_repo(dir.path());
6612 let status = git_status_cheap(dir.path());
6613 assert_eq!(status.branch.as_deref(), Some("main"));
6614 assert_eq!(status.ahead, None);
6615 assert_eq!(status.behind, None);
6616 assert_eq!(
6617 status.main_repo.as_deref(),
6618 dir.path().file_name().and_then(|n| n.to_str())
6619 );
6620 let head = repo.head().unwrap().target().unwrap();
6623 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6624 }
6625
6626 #[test]
6629 fn git_status_head_sha_tracks_new_commits() {
6630 let dir = tempfile::tempdir().unwrap();
6636 let repo = init_repo(dir.path());
6637 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6638 repo.set_head("refs/heads/main").unwrap();
6639 let before = git_status_cheap(dir.path());
6640 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6641
6642 let head = repo.find_commit(a).unwrap();
6643 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6644 let after = git_status_cheap(dir.path());
6645 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6646 assert_ne!(before.head_sha, after.head_sha);
6647 assert_eq!(before.branch, after.branch);
6650 }
6651
6652 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6658 repo.reference("refs/remotes/origin/main", oid, true, "push")
6659 .unwrap();
6660 }
6661
6662 #[test]
6663 fn git_status_upstream_sha_tracks_a_push() {
6664 let dir = tempfile::tempdir().unwrap();
6671 let repo = diverging_repo(dir.path());
6672 let before = git_status(dir.path());
6673 assert_eq!(before.ahead, Some(1));
6674 assert_eq!(before.behind, Some(1));
6675
6676 let head = repo.head().unwrap().target().unwrap();
6677 simulate_push(&repo, head);
6678 let after = git_status(dir.path());
6679
6680 assert_eq!(
6682 after.upstream_sha.as_deref(),
6683 Some(head.to_string().as_str())
6684 );
6685 assert_ne!(before.upstream_sha, after.upstream_sha);
6686 assert_eq!(after.ahead, Some(0));
6687 assert_eq!(after.behind, Some(0));
6688 assert_eq!(before.branch, after.branch);
6692 assert_eq!(before.head_sha, after.head_sha);
6693 }
6694
6695 #[test]
6696 fn git_status_cheap_reports_upstream_sha() {
6697 let dir = tempfile::tempdir().unwrap();
6702 let repo = diverging_repo(dir.path());
6703 let status = git_status_cheap(dir.path());
6704 let upstream = repo
6705 .find_branch("origin/main", git2::BranchType::Remote)
6706 .unwrap()
6707 .get()
6708 .target()
6709 .unwrap();
6710 assert_eq!(
6711 status.upstream_sha.as_deref(),
6712 Some(upstream.to_string().as_str())
6713 );
6714 assert_eq!(status.ahead, None);
6715 assert_eq!(status.behind, None);
6716 }
6717
6718 #[test]
6719 fn folder_ahead_behind_computes_divergence_and_degrades() {
6720 let dir = tempfile::tempdir().unwrap();
6722 let _repo = diverging_repo(dir.path());
6723 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6724
6725 let no_up = tempfile::tempdir().unwrap();
6727 let repo = init_repo(no_up.path());
6728 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6729 repo.set_head("refs/heads/main").unwrap();
6730 assert_eq!(folder_ahead_behind(no_up.path()), None);
6731
6732 let detached = tempfile::tempdir().unwrap();
6734 let drepo = init_repo(detached.path());
6735 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6736 drepo.set_head_detached(a).unwrap();
6737 assert_eq!(folder_ahead_behind(detached.path()), None);
6738 let plain = tempfile::tempdir().unwrap();
6739 assert_eq!(folder_ahead_behind(plain.path()), None);
6740 }
6741
6742 #[test]
6745 fn folder_main_behind_computes_divergence_and_degrades() {
6746 let dir = tempfile::tempdir().unwrap();
6750 let _repo = behind_main_no_upstream_repo(dir.path());
6751 assert_eq!(folder_main_behind(dir.path()), Some(1));
6752
6753 let detached = tempfile::tempdir().unwrap();
6755 let drepo = init_repo(detached.path());
6756 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6757 drepo.set_head_detached(a).unwrap();
6758 assert_eq!(folder_main_behind(detached.path()), None);
6759 let plain = tempfile::tempdir().unwrap();
6760 assert_eq!(folder_main_behind(plain.path()), None);
6761 }
6762
6763 #[test]
6764 fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6765 let dir = tempfile::tempdir().unwrap();
6770 let _repo = diverging_repo(dir.path());
6771 assert_eq!(folder_main_behind(dir.path()), None);
6772 }
6773
6774 #[test]
6775 fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6776 let dir = tempfile::tempdir().unwrap();
6778 let repo = init_repo(dir.path());
6779 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6780 repo.set_head("refs/heads/main").unwrap();
6781 assert_eq!(folder_main_behind(dir.path()), None);
6782 }
6783
6784 #[test]
6785 fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6786 let dir = tempfile::tempdir().unwrap();
6787 let repo = init_repo(dir.path());
6788 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6789 let base_commit = repo.find_commit(base).unwrap();
6790
6791 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6793 let m1_commit = repo.find_commit(m1).unwrap();
6794 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6795 let m2_commit = repo.find_commit(m2).unwrap();
6796 let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6797 repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6798 .unwrap();
6799
6800 let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6803 repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6804 .unwrap();
6805 empty_commit(
6806 &repo,
6807 Some("refs/heads/feature"),
6808 &[&base_commit],
6809 "local-feature",
6810 );
6811 drop(base_commit);
6812 drop(m1_commit);
6813 drop(m2_commit);
6814
6815 repo.set_head("refs/heads/feature").unwrap();
6816 let mut cfg = repo.config().unwrap();
6817 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6818 .unwrap();
6819 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6820 .unwrap();
6821 cfg.set_str("branch.feature.remote", "origin").unwrap();
6822 cfg.set_str("branch.feature.merge", "refs/heads/feature")
6823 .unwrap();
6824
6825 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6826 assert_eq!(folder_main_behind(dir.path()), Some(3));
6827 }
6828
6829 #[tokio::test]
6830 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6831 let diverging = tempfile::tempdir().unwrap();
6832 let _d = diverging_repo(diverging.path());
6833 let no_up = tempfile::tempdir().unwrap();
6834 let repo = init_repo(no_up.path());
6835 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6836 repo.set_head("refs/heads/main").unwrap();
6837
6838 let svc = WorktreesService::new();
6839 let diverging_path = diverging.path().display().to_string();
6840 let no_up_path = no_up.path().display().to_string();
6841 let reply = svc
6842 .handle(
6843 "ahead-behind",
6844 json!({ "paths": [&diverging_path, &no_up_path] }),
6845 )
6846 .await
6847 .unwrap();
6848 let results = reply.get("results").unwrap();
6849 let d = results.get(diverging_path.as_str()).unwrap();
6853 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6854 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6855 assert!(d.get("main_behind").is_none(), "{d:?}");
6856 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6860
6861 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6863 assert_eq!(empty.get("results"), Some(&json!({})));
6864 }
6865
6866 #[tokio::test]
6867 async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6868 let dir = tempfile::tempdir().unwrap();
6869 let _repo = behind_main_no_upstream_repo(dir.path());
6870 let svc = WorktreesService::new();
6871 let path = dir.path().display().to_string();
6872 let reply = svc
6873 .handle("ahead-behind", json!({ "paths": [&path] }))
6874 .await
6875 .unwrap();
6876 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6877 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6878 assert!(entry.get("ahead").is_none(), "{entry:?}");
6880 assert!(entry.get("behind").is_none(), "{entry:?}");
6881 }
6882
6883 #[tokio::test]
6884 async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6885 let dir = tempfile::tempdir().unwrap();
6890 let repo = init_repo(dir.path());
6891 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6892 let base_commit = repo.find_commit(base).unwrap();
6893
6894 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6895 let m1_commit = repo.find_commit(m1).unwrap();
6896 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6897 repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6898 .unwrap();
6899
6900 let r = empty_commit(
6901 &repo,
6902 Some("refs/heads/release"),
6903 &[&base_commit],
6904 "release",
6905 );
6906 repo.reference("refs/remotes/origin/release", r, true, "origin release")
6907 .unwrap();
6908 drop(base_commit);
6909 drop(m1_commit);
6910
6911 repo.set_head("refs/heads/release").unwrap();
6912 let mut cfg = repo.config().unwrap();
6913 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6914 .unwrap();
6915 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6916 .unwrap();
6917 cfg.set_str("branch.release.remote", "origin").unwrap();
6918 cfg.set_str("branch.release.merge", "refs/heads/release")
6919 .unwrap();
6920
6921 let svc = WorktreesService::new();
6922 let path = dir.path().display().to_string();
6923 let reply = svc
6924 .handle("ahead-behind", json!({ "paths": [&path] }))
6925 .await
6926 .unwrap();
6927 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6928 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6929 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6930 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6931 }
6932
6933 #[tokio::test]
6934 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6935 let dir = tempfile::tempdir().unwrap();
6937 let _repo = diverging_repo(dir.path());
6938 let svc = WorktreesService::new();
6939 svc.handle(
6940 "register",
6941 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6942 )
6943 .await
6944 .unwrap();
6945
6946 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6947 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6948 let main_wt = &worktrees[0];
6949 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6952 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6953 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6954 }
6955
6956 #[tokio::test]
6957 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6958 let dir = tempfile::tempdir().unwrap();
6963 let repo = init_repo(dir.path());
6964 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6965 repo.set_head("refs/heads/main").unwrap();
6966
6967 let svc = WorktreesService::new();
6968 svc.handle(
6969 "register",
6970 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6971 )
6972 .await
6973 .unwrap();
6974
6975 let before = svc.handle("tree", Value::Null).await.unwrap();
6976 let wt = &repos_of(&before)[0]["worktrees"][0];
6977 assert_eq!(
6978 wt.get("head_sha").and_then(Value::as_str),
6979 Some(a.to_string().as_str())
6980 );
6981
6982 let head = repo.find_commit(a).unwrap();
6985 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6986 let after = svc.handle("tree", Value::Null).await.unwrap();
6987 assert_eq!(
6988 repos_of(&after)[0]["worktrees"][0]
6989 .get("head_sha")
6990 .and_then(Value::as_str),
6991 Some(b.to_string().as_str())
6992 );
6993 assert_ne!(before, after, "a commit must be a visible snapshot delta");
6994 }
6995
6996 #[tokio::test]
6997 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6998 let dir = tempfile::tempdir().unwrap();
7001 init_repo(dir.path());
7002 let svc = WorktreesService::new();
7003 svc.handle(
7004 "register",
7005 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7006 )
7007 .await
7008 .unwrap();
7009 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7010 assert!(wt.get("head_sha").is_none(), "{wt:?}");
7011 }
7012
7013 #[tokio::test]
7016 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7017 let dir = tempfile::tempdir().unwrap();
7023 let repo = diverging_repo(dir.path());
7024 let svc = WorktreesService::new();
7025 svc.handle(
7026 "register",
7027 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7028 )
7029 .await
7030 .unwrap();
7031
7032 let before = svc.handle("tree", Value::Null).await.unwrap();
7033 let head = repo.head().unwrap().target().unwrap();
7034 assert_ne!(
7035 repos_of(&before)[0]["worktrees"][0]
7036 .get("upstream_sha")
7037 .and_then(Value::as_str),
7038 Some(head.to_string().as_str()),
7039 "the fixture must start un-pushed for this to prove anything"
7040 );
7041
7042 simulate_push(&repo, head);
7044 let after = svc.handle("tree", Value::Null).await.unwrap();
7045 let wt = &repos_of(&after)[0]["worktrees"][0];
7046 assert_eq!(
7047 wt.get("upstream_sha").and_then(Value::as_str),
7048 Some(head.to_string().as_str())
7049 );
7050 assert_eq!(
7053 wt.get("head_sha").and_then(Value::as_str),
7054 repos_of(&before)[0]["worktrees"][0]
7055 .get("head_sha")
7056 .and_then(Value::as_str)
7057 );
7058 assert_ne!(before, after, "a push must be a visible snapshot delta");
7059 }
7060
7061 #[tokio::test]
7062 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7063 let dir = tempfile::tempdir().unwrap();
7067 let repo = init_repo(dir.path());
7068 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7069 repo.set_head("refs/heads/main").unwrap();
7070 let svc = WorktreesService::new();
7071 svc.handle(
7072 "register",
7073 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7074 )
7075 .await
7076 .unwrap();
7077 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7078 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7079 assert!(wt.get("head_sha").is_some(), "{wt:?}");
7081 }
7082
7083 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7093 let guard = shim_lock();
7094 let path = dir.join("fake-gh");
7095 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7096 (path, guard)
7097 }
7098
7099 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7104 let guard = shim_lock();
7105 let path = dir.join("fake-gh");
7106 let counter = dir.join("gh-calls");
7107 write_exec_script(
7108 &path,
7109 &format!(
7110 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7111 counter = counter.display()
7112 ),
7113 );
7114 (path, guard, counter)
7115 }
7116
7117 fn gh_spawn_count(counter: &Path) -> usize {
7120 std::fs::read(counter).map_or(0, |b| b.len())
7121 }
7122
7123 fn counted_gh_records(log: &Path) -> usize {
7128 std::fs::read_to_string(log)
7129 .unwrap_or_default()
7130 .lines()
7131 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7132 .count()
7133 }
7134
7135 fn github_repo(dir: &Path) -> Repository {
7137 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7138 }
7139
7140 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7143 let repo = init_repo(dir);
7144 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7145 repo.set_head("refs/heads/main").unwrap();
7146 repo.remote("origin", url).unwrap();
7147 repo
7148 }
7149
7150 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7155 PrBadge {
7156 number,
7157 is_draft: false,
7158 checks: PrCheckState::Pending,
7159 url: "u".into(),
7160 head_oid: head_oid.to_string(),
7161 }
7162 }
7163
7164 fn pr(badge: PrBadge) -> PrResolution {
7166 PrResolution::Pr(badge)
7167 }
7168
7169 #[test]
7170 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7171 let snapshot = json!({"repos":[
7172 {
7173 "main_repo":"omni-dev",
7174 "github":{"owner":"rust-works","name":"omni-dev"},
7175 "root":"/r",
7176 "polling_enabled":true,
7179 "worktrees":[
7181 {"path":"/r","branch":"main","is_main":true,"open":true},
7182 {"path":"/w1","branch":"main","is_main":false,"open":true},
7183 {"path":"/w2","branch":"feature","is_main":false,"open":true},
7184 {"path":"/w3","is_main":false,"open":true}
7186 ]
7187 },
7188 {
7189 "main_repo":"local","root":"/l",
7191 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7192 }
7193 ]});
7194 let targets = pr_targets_from_snapshot(&snapshot);
7195 assert_eq!(
7196 targets,
7197 vec![
7198 PrTarget {
7199 owner: "rust-works".into(),
7200 name: "omni-dev".into(),
7201 branch: "feature".into()
7202 },
7203 PrTarget {
7204 owner: "rust-works".into(),
7205 name: "omni-dev".into(),
7206 branch: "main".into()
7207 },
7208 ]
7209 );
7210 }
7211
7212 #[test]
7213 fn pr_targets_from_snapshot_is_empty_without_repos() {
7214 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7215 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7216 }
7217
7218 #[test]
7219 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7220 for github in [
7223 json!({}),
7224 json!({"owner": "o"}),
7225 json!({"owner": 1, "name": 2}),
7226 ] {
7227 let snapshot = json!({"repos":[{
7228 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7229 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7230 }]});
7231 assert!(
7232 pr_targets_from_snapshot(&snapshot).is_empty(),
7233 "{snapshot:?}"
7234 );
7235 }
7236 }
7237
7238 #[test]
7239 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7240 for repo in [
7244 json!({
7245 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7246 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7247 }),
7248 json!({
7249 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7250 "polling_enabled":false,
7251 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7252 }),
7253 ] {
7254 let snapshot = json!({ "repos": [repo] });
7255 assert!(
7256 pr_targets_from_snapshot(&snapshot).is_empty(),
7257 "not-polled repo must yield no targets: {snapshot:?}"
7258 );
7259 }
7260 let enabled = json!({"repos":[{
7262 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7263 "polling_enabled":true,
7264 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7265 }]});
7266 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7267 }
7268
7269 #[test]
7270 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7271 let backoff = Duration::from_secs(600);
7272 assert!(pr_should_fetch(false, None, backoff));
7274 assert!(!pr_should_fetch(
7277 false,
7278 Some(Duration::from_secs(1)),
7279 backoff
7280 ));
7281 assert!(pr_should_fetch(false, Some(backoff), backoff));
7283 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7284 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7288 assert!(pr_should_fetch(
7289 true,
7290 Some(Duration::from_millis(1)),
7291 backoff
7292 ));
7293 }
7294
7295 #[test]
7296 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7297 let base = Duration::from_secs(10);
7298 let fresh = Some(Duration::ZERO);
7299 let stale = Some(PENDING_FAST_WINDOW);
7300 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7303 assert_eq!(
7304 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7305 base
7306 );
7307 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7310 assert_eq!(
7311 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7312 PENDING_MAX_INTERVAL
7313 );
7314 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7317 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7319 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7320 assert_eq!(
7322 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7323 MAX_PR_POLL_INTERVAL
7324 );
7325 assert_eq!(
7326 next_pr_poll_delay(Duration::MAX, base, false, None),
7327 MAX_PR_POLL_INTERVAL
7328 );
7329 }
7330
7331 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7333 PrWatch {
7334 target: PrTarget {
7335 owner: "rust-works".into(),
7336 name: "omni-dev".into(),
7337 branch: branch.into(),
7338 },
7339 upstream_sha: upstream.map(str::to_string),
7340 }
7341 }
7342
7343 #[test]
7344 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7345 let a = watch("a", Some("111"));
7346 let b = watch("b", Some("222"));
7347 let ab = [a.clone(), b.clone()];
7348 let just_a = std::slice::from_ref(&a);
7349 let just_b = std::slice::from_ref(&b);
7350 assert!(!pr_watch_grew(&ab, &ab));
7352 assert!(pr_watch_grew(just_a, &ab));
7354 assert!(!pr_watch_grew(&ab, just_a));
7356 assert!(pr_watch_grew(&[], just_a));
7358 let a_pushed = [watch("a", Some("999"))];
7360 assert!(pr_watch_grew(just_a, &a_pushed));
7361 assert!(pr_watch_grew(just_a, just_b));
7363 }
7364
7365 #[test]
7366 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7367 let base = Duration::from_secs(10);
7368 let over = RateLimitSnapshot {
7369 graphql: Some(rl_resource(90)),
7370 core: Some(rl_resource(3)),
7371 search: None,
7372 };
7373 let under = RateLimitSnapshot {
7374 graphql: Some(rl_resource(50)),
7375 core: Some(rl_resource(3)),
7376 search: None,
7377 };
7378 assert_eq!(budget_throttled_delay(base, None), base);
7380 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7382 assert_eq!(
7384 budget_throttled_delay(base, Some(&over)),
7385 BUDGET_THROTTLE_INTERVAL
7386 );
7387 let long = BUDGET_THROTTLE_INTERVAL * 2;
7389 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7390 }
7391
7392 #[test]
7393 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7394 let target = PrTarget {
7398 owner: "rust-works".into(),
7399 name: "omni-dev".into(),
7400 branch: "main".into(),
7401 };
7402 let badge = PrResolution::Pr(PrBadge {
7403 number: 1337,
7404 is_draft: true,
7405 checks: PrCheckState::Pending,
7406 url: "http://x/1337".into(),
7407 head_oid: "deadbeef".into(),
7408 });
7409 let watched = vec![watch("main", Some("abc"))];
7410 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7411 .unwrap()
7412 .with_timezone(&Utc);
7413 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7414
7415 let json = serde_json::to_vec(&prefs).unwrap();
7416 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7417 assert_eq!(back, prefs);
7418 assert_eq!(back.polled_at, Some(polled_at));
7419 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7420 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7422 }
7423
7424 #[test]
7425 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7426 let target = PrTarget {
7430 owner: "rust-works".into(),
7431 name: "omni-dev".into(),
7432 branch: "feature".into(),
7433 };
7434 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7435 let json = serde_json::to_vec(&prefs).unwrap();
7436 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7437 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7438 assert_eq!(
7439 back.entries[0].resolution.clone().into_resolution(),
7440 PrResolution::NoPr
7441 );
7442 }
7443
7444 #[test]
7445 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7446 let dir = tempfile::tempdir().unwrap();
7451 let path = dir.path().join("pr-cache.json");
7452 let target = PrTarget {
7453 owner: "rust-works".into(),
7454 name: "omni-dev".into(),
7455 branch: "main".into(),
7456 };
7457 let mut prefs = pr_cache_prefs_from(
7458 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7459 &[watch("main", None)],
7460 Utc::now(),
7461 );
7462 prefs.polled_at = None;
7463 write_pr_cache(&path, &prefs).unwrap();
7464
7465 let svc = WorktreesService::new();
7466 svc.load_pr_cache(path);
7467 assert!(
7468 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7469 "the badge itself must still restore"
7470 );
7471 assert!(
7472 svc.pr_warm_start
7473 .lock()
7474 .unwrap_or_else(PoisonError::into_inner)
7475 .is_none(),
7476 "no poll time means a cold start, not a trusted warm one"
7477 );
7478 }
7479
7480 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7484 tracing::subscriber::set_default(
7485 tracing_subscriber::fmt()
7486 .with_max_level(tracing::Level::WARN)
7487 .with_writer(std::io::sink)
7488 .finish(),
7489 )
7490 }
7491
7492 #[test]
7493 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7494 let _trace = warn_subscriber();
7498 let dir = tempfile::tempdir().unwrap();
7499 let corrupt = dir.path().join("pr-cache.json");
7500 std::fs::write(&corrupt, b"not json").unwrap();
7501 let svc = WorktreesService::new();
7502 svc.load_pr_cache(corrupt.clone());
7503 assert!(svc.pr_cache.entries().is_empty());
7504 assert_eq!(
7505 svc.pr_cache_path
7506 .lock()
7507 .unwrap_or_else(PoisonError::into_inner)
7508 .as_deref(),
7509 Some(corrupt.as_path()),
7510 "the path must be stored even when the load fails, so persistence recovers"
7511 );
7512
7513 let svc = WorktreesService::new();
7516 svc.load_pr_cache(dir.path().to_path_buf());
7517 assert!(svc.pr_cache.entries().is_empty());
7518 }
7519
7520 #[test]
7521 fn persist_pr_cache_swallows_a_write_failure() {
7522 let _trace = warn_subscriber();
7526 let dir = tempfile::tempdir().unwrap();
7527 let blocker = dir.path().join("blocker");
7528 std::fs::write(&blocker, b"").unwrap();
7529 let path = blocker.join("pr-cache.json");
7531 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7532 assert!(!path.exists());
7533 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7536 }
7537
7538 #[tokio::test]
7539 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7540 let dir = tempfile::tempdir().unwrap();
7541 let repo = github_repo(dir.path());
7542 let head = repo.head().unwrap().target().unwrap().to_string();
7543 let svc = WorktreesService::new();
7544 svc.handle(
7545 "register",
7546 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7547 )
7548 .await
7549 .unwrap();
7550 svc.registry.set_polling("rust-works", "omni-dev", true);
7553
7554 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7557 assert!(wt.get("pr").is_none(), "{wt:?}");
7558 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7559
7560 let mut badges = HashMap::new();
7562 badges.insert(
7563 PrTarget {
7564 owner: "rust-works".into(),
7565 name: "omni-dev".into(),
7566 branch: "main".into(),
7567 },
7568 pr(pending_badge(1337, &head)),
7569 );
7570 assert!(svc.pr_cache.replace(badges));
7571
7572 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7573 assert_eq!(wt["pr"]["number"], json!(1337));
7574 assert_eq!(wt["pr"]["checks"], json!("pending"));
7575 assert_eq!(wt["pr"]["isDraft"], json!(false));
7577 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7579 }
7580
7581 #[tokio::test]
7582 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7583 let dir = tempfile::tempdir().unwrap();
7588 let repo = github_repo(dir.path());
7589 let head = repo.head().unwrap().target().unwrap();
7590 repo.set_head_detached(head).unwrap();
7591
7592 let svc = WorktreesService::new();
7593 svc.handle(
7594 "register",
7595 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7596 )
7597 .await
7598 .unwrap();
7599 svc.registry.set_polling("rust-works", "omni-dev", true);
7602 let mut badges = HashMap::new();
7605 badges.insert(
7606 PrTarget {
7607 owner: "rust-works".into(),
7608 name: "omni-dev".into(),
7609 branch: "main".into(),
7610 },
7611 pr(pending_badge(1, &head.to_string())),
7612 );
7613 svc.pr_cache.replace(badges);
7614
7615 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7616 assert!(wt.get("branch").is_none(), "{wt:?}");
7617 assert_eq!(
7619 wt.get("head_sha").and_then(Value::as_str),
7620 Some(head.to_string().as_str())
7621 );
7622 assert!(wt.get("pr").is_none(), "{wt:?}");
7623 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7625 }
7626
7627 #[tokio::test]
7628 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7629 let dir = tempfile::tempdir().unwrap();
7630 github_repo(dir.path());
7631 let svc = WorktreesService::new();
7632 svc.handle(
7633 "register",
7634 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7635 )
7636 .await
7637 .unwrap();
7638 svc.registry.set_polling("rust-works", "omni-dev", true);
7641 let mut badges = HashMap::new();
7643 badges.insert(
7644 PrTarget {
7645 owner: "rust-works".into(),
7646 name: "omni-dev".into(),
7647 branch: "other".into(),
7648 },
7649 pr(pending_badge(1, "irrelevant")),
7650 );
7651 svc.pr_cache.replace(badges);
7652 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7653 assert!(wt.get("pr").is_none(), "{wt:?}");
7654 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7656 }
7657
7658 #[tokio::test]
7659 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7660 let dir = tempfile::tempdir().unwrap();
7664 github_repo(dir.path());
7665 let svc = WorktreesService::new();
7666 svc.handle(
7667 "register",
7668 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7669 )
7670 .await
7671 .unwrap();
7672 svc.registry.set_polling("rust-works", "omni-dev", true);
7675
7676 let mut resolutions = HashMap::new();
7677 resolutions.insert(
7678 PrTarget {
7679 owner: "rust-works".into(),
7680 name: "omni-dev".into(),
7681 branch: "main".into(),
7682 },
7683 PrResolution::NoPr,
7684 );
7685 assert!(svc.pr_cache.replace(resolutions));
7686
7687 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7688 assert_eq!(wt["pr_none"], json!(true));
7689 assert!(wt.get("pr").is_none(), "{wt:?}");
7691 }
7692
7693 #[tokio::test]
7694 async fn a_commit_does_not_drop_a_negative_resolution() {
7695 let dir = tempfile::tempdir().unwrap();
7700 let repo = github_repo(dir.path());
7701 let first = repo.head().unwrap().target().unwrap();
7702
7703 let svc = WorktreesService::new();
7704 svc.handle(
7705 "register",
7706 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7707 )
7708 .await
7709 .unwrap();
7710 svc.registry.set_polling("rust-works", "omni-dev", true);
7713
7714 let mut resolutions = HashMap::new();
7715 resolutions.insert(
7716 PrTarget {
7717 owner: "rust-works".into(),
7718 name: "omni-dev".into(),
7719 branch: "main".into(),
7720 },
7721 PrResolution::NoPr,
7722 );
7723 svc.pr_cache.replace(resolutions);
7724
7725 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726 assert_eq!(wt["pr_none"], json!(true));
7727
7728 let head = repo.find_commit(first).unwrap();
7730 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7731
7732 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7733 assert_eq!(
7734 wt["pr_none"],
7735 json!(true),
7736 "a local commit must not drop the negative"
7737 );
7738 }
7739
7740 #[tokio::test]
7741 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7742 let bin_dir = tempfile::tempdir().unwrap();
7746 let marker = bin_dir.path().join("spawned");
7747 let fake = bin_dir.path().join("fake-gh");
7748 std::fs::write(
7749 &fake,
7750 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7751 )
7752 .unwrap();
7753 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7754 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7755 std::fs::set_permissions(&fake, perms).unwrap();
7756
7757 let svc = WorktreesService::new();
7758 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7759 tokio::time::sleep(Duration::from_millis(200)).await;
7760 svc.shutdown().await;
7761 assert!(
7762 !marker.exists(),
7763 "the poller must not spawn gh with no windows registered"
7764 );
7765 }
7766
7767 #[tokio::test]
7768 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7769 let dir = tempfile::tempdir().unwrap();
7772 let repo = github_repo(dir.path());
7773 let head = repo.head().unwrap().target().unwrap().to_string();
7774 let bin_dir = tempfile::tempdir().unwrap();
7775 let fake = bin_dir.path().join("fake-gh");
7776 std::fs::write(
7778 &fake,
7779 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7780 )
7781 .unwrap();
7782 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7783 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7784 std::fs::set_permissions(&fake, perms).unwrap();
7785
7786 let svc = WorktreesService::new();
7787 svc.handle(
7788 "register",
7789 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7790 )
7791 .await
7792 .unwrap();
7793 svc.registry.set_polling("rust-works", "omni-dev", true);
7796 let mut seeded = HashMap::new();
7798 seeded.insert(
7799 PrTarget {
7800 owner: "rust-works".into(),
7801 name: "omni-dev".into(),
7802 branch: "main".into(),
7803 },
7804 pr(pending_badge(7, &head)),
7805 );
7806 svc.pr_cache.replace(seeded);
7807
7808 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7809 tokio::time::sleep(Duration::from_millis(200)).await;
7810
7811 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7814 assert_eq!(wt["pr"]["number"], json!(7));
7815 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7816 svc.shutdown().await;
7817 }
7818
7819 #[tokio::test]
7820 #[allow(clippy::await_holding_lock)]
7827 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7828 let dir = tempfile::tempdir().unwrap();
7834 github_repo(dir.path());
7835 let bin_dir = tempfile::tempdir().unwrap();
7836 let (fake, _shim) = fake_gh(
7837 bin_dir.path(),
7838 r#"{"data":{"r0":{"b0":{
7839 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7840 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7841 ]}}},
7842 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7843 }}}}"#,
7844 );
7845
7846 let svc = WorktreesService::new();
7847 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7849 tokio::time::sleep(Duration::from_millis(150)).await;
7850
7851 svc.handle(
7853 "register",
7854 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7855 )
7856 .await
7857 .unwrap();
7858 svc.registry.set_polling("rust-works", "omni-dev", true);
7861
7862 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7866 loop {
7867 if let Some(PrResolution::Pr(badge)) =
7868 svc.pr_cache.get("rust-works", "omni-dev", "main")
7869 {
7870 return badge;
7871 }
7872 tokio::time::sleep(Duration::from_millis(25)).await;
7873 }
7874 })
7875 .await
7876 .expect("a window opening must wake the poller out of its idle backoff");
7877 assert_eq!(badge.number, 99);
7878 svc.shutdown().await;
7879 }
7880
7881 #[tokio::test]
7882 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7883 let dir = tempfile::tempdir().unwrap();
7890 let repo = github_repo(dir.path());
7891 let first = repo.head().unwrap().target().unwrap();
7892
7893 let svc = WorktreesService::new();
7894 svc.handle(
7895 "register",
7896 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7897 )
7898 .await
7899 .unwrap();
7900 svc.registry.set_polling("rust-works", "omni-dev", true);
7903
7904 let mut badges = HashMap::new();
7906 badges.insert(
7907 PrTarget {
7908 owner: "rust-works".into(),
7909 name: "omni-dev".into(),
7910 branch: "main".into(),
7911 },
7912 pr(PrBadge {
7913 number: 1337,
7914 is_draft: false,
7915 checks: PrCheckState::Success,
7916 url: "u".into(),
7917 head_oid: first.to_string(),
7918 }),
7919 );
7920 svc.pr_cache.replace(badges);
7921
7922 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7923 assert_eq!(
7924 wt["pr"]["checks"],
7925 json!("success"),
7926 "green for its own commit"
7927 );
7928
7929 let head = repo.find_commit(first).unwrap();
7931 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7932
7933 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7934 assert_eq!(
7935 wt["pr"]["checks"],
7936 json!("pending"),
7937 "the previous commit's ✓ must not stand after a new commit"
7938 );
7939 assert_eq!(wt["pr"]["number"], json!(1337));
7942 }
7943
7944 #[test]
7945 fn is_stale_for_compares_the_commit_the_verdict_describes() {
7946 let badge = pending_badge(1, "aaa");
7947 assert!(!badge.is_stale_for(Some("aaa")));
7948 assert!(badge.is_stale_for(Some("bbb")));
7949 assert!(!badge.is_stale_for(None));
7951 }
7952
7953 #[test]
7954 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7955 let snap = |sha: &str| {
7960 json!({"repos":[{
7961 "main_repo":"omni-dev",
7962 "github":{"owner":"rust-works","name":"omni-dev"},
7963 "root":"/r",
7964 "polling_enabled":true,
7965 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7966 }]})
7967 };
7968 let before = pr_watch_from_snapshot(&snap("aaa"));
7969 let after = pr_watch_from_snapshot(&snap("bbb"));
7970 assert_eq!(before.len(), 1);
7971 assert_eq!(before[0].target, after[0].target);
7972 assert_eq!(before, after);
7974 assert!(!pr_watch_grew(&before, &after));
7975 }
7976
7977 #[test]
7978 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7979 let snap = |upstream: &str| {
7983 json!({"repos":[{
7984 "main_repo":"omni-dev",
7985 "github":{"owner":"rust-works","name":"omni-dev"},
7986 "root":"/r",
7987 "polling_enabled":true,
7988 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7989 "upstream_sha":upstream,"is_main":true,"open":true}]
7990 }]})
7991 };
7992 let before = pr_watch_from_snapshot(&snap("aaa"));
7993 let after = pr_watch_from_snapshot(&snap("bbb"));
7994 assert_eq!(before.len(), 1);
7996 assert_eq!(before[0].target, after[0].target);
7997 assert_ne!(before, after);
7998 assert!(pr_watch_grew(&before, &after));
7999 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8001 assert!(!pr_watch_grew(
8002 &before,
8003 &pr_watch_from_snapshot(&snap("aaa"))
8004 ));
8005 }
8006
8007 #[test]
8008 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8009 let snap = json!({"repos":[{
8012 "main_repo":"omni-dev",
8013 "github":{"owner":"rust-works","name":"omni-dev"},
8014 "root":"/r",
8015 "polling_enabled":true,
8016 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8017 }]});
8018 let watch = pr_watch_from_snapshot(&snap);
8019 assert_eq!(watch.len(), 1);
8020 assert_eq!(watch[0].upstream_sha, None);
8021 }
8022
8023 #[test]
8024 fn start_pr_poller_is_a_noop_outside_a_runtime() {
8025 let svc = WorktreesService::new();
8026 svc.start_pr_poller();
8027 assert!(svc
8028 .poller
8029 .lock()
8030 .unwrap_or_else(PoisonError::into_inner)
8031 .is_none());
8032 }
8033
8034 #[tokio::test]
8035 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8036 let svc = WorktreesService::new();
8037 svc.start_pr_poller_with(
8038 Duration::from_millis(50),
8039 Duration::from_millis(10),
8040 PathBuf::from("/bin/true"),
8041 );
8042 let token = svc
8043 .poller
8044 .lock()
8045 .unwrap_or_else(PoisonError::into_inner)
8046 .as_ref()
8047 .map(|t| t.token.clone())
8048 .expect("poller started");
8049
8050 token.cancel();
8053 svc.start_pr_poller_with(
8054 Duration::from_millis(50),
8055 Duration::from_millis(10),
8056 PathBuf::from("/bin/true"),
8057 );
8058 assert!(svc
8059 .poller
8060 .lock()
8061 .unwrap_or_else(PoisonError::into_inner)
8062 .as_ref()
8063 .is_some_and(|t| t.token.is_cancelled()));
8064
8065 svc.shutdown().await;
8066 assert!(svc
8067 .poller
8068 .lock()
8069 .unwrap_or_else(PoisonError::into_inner)
8070 .is_none());
8071 }
8072
8073 fn rl_resource(used: u64) -> RateLimitResource {
8077 RateLimitResource {
8078 used,
8079 limit: 100,
8080 remaining: 100 - used,
8081 percent: used as f64,
8082 reset: 0,
8083 }
8084 }
8085
8086 #[test]
8087 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8088 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8089 graphql: Some(rl_resource(graphql)),
8090 core: Some(rl_resource(core)),
8091 search: None,
8092 };
8093 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8095 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8097 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8099 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8101 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8103 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8105 }
8106
8107 #[test]
8108 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8109 let svc = WorktreesService::new();
8110 svc.start_rate_limit_poller();
8111 assert!(svc
8112 .rate_limit_poller
8113 .lock()
8114 .unwrap_or_else(PoisonError::into_inner)
8115 .is_none());
8116 }
8117
8118 #[tokio::test]
8119 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8120 let svc = WorktreesService::new();
8121 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8122 let token = svc
8123 .rate_limit_poller
8124 .lock()
8125 .unwrap_or_else(PoisonError::into_inner)
8126 .as_ref()
8127 .map(|t| t.token.clone())
8128 .expect("poller started");
8129
8130 token.cancel();
8133 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8134 assert!(svc
8135 .rate_limit_poller
8136 .lock()
8137 .unwrap_or_else(PoisonError::into_inner)
8138 .as_ref()
8139 .is_some_and(|t| t.token.is_cancelled()));
8140
8141 svc.shutdown().await;
8142 assert!(svc
8143 .rate_limit_poller
8144 .lock()
8145 .unwrap_or_else(PoisonError::into_inner)
8146 .is_none());
8147 }
8148
8149 #[tokio::test]
8150 #[allow(clippy::await_holding_lock)]
8152 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8153 let bin_dir = tempfile::tempdir().unwrap();
8154 let (fake, _shim) = fake_gh(
8155 bin_dir.path(),
8156 r#"{"resources":{
8157 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8158 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8159 }}"#,
8160 );
8161 let svc = WorktreesService::new();
8162 svc.registry.set_polling("rust-works", "omni-dev", true);
8165 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8166
8167 let snap = tokio::time::timeout(Duration::from_secs(30), async {
8170 loop {
8171 if let Some(snap) = svc.rate_limit_cache.get() {
8172 return snap;
8173 }
8174 tokio::time::sleep(Duration::from_millis(25)).await;
8175 }
8176 })
8177 .await
8178 .expect("poller should populate the cache through the fake gh");
8179 assert_eq!(snap.graphql.unwrap().used, 4100);
8180 assert_eq!(snap.core.unwrap().used, 27);
8181
8182 assert!(svc.rate_limit_cache().get().is_some());
8184
8185 svc.shutdown().await;
8186 assert!(svc
8187 .rate_limit_poller
8188 .lock()
8189 .unwrap_or_else(PoisonError::into_inner)
8190 .is_none());
8191 }
8192
8193 #[tokio::test]
8194 #[allow(clippy::await_holding_lock)]
8196 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8197 let bin_dir = tempfile::tempdir().unwrap();
8200 let (fake, _shim, counter) = counting_fake_gh(
8201 bin_dir.path(),
8202 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8203 );
8204 let svc = WorktreesService::new();
8205 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8206
8207 tokio::time::sleep(Duration::from_millis(300)).await;
8209 assert_eq!(
8210 gh_spawn_count(&counter),
8211 0,
8212 "idle daemon must not poll (#1389, fix 8b)"
8213 );
8214 assert!(svc.rate_limit_cache.get().is_none());
8215
8216 svc.registry.set_polling("rust-works", "omni-dev", true);
8218 tokio::time::timeout(Duration::from_secs(30), async {
8219 loop {
8220 if svc.rate_limit_cache.get().is_some() {
8221 return;
8222 }
8223 tokio::time::sleep(Duration::from_millis(25)).await;
8224 }
8225 })
8226 .await
8227 .expect("an active lease should resume polling");
8228 assert!(gh_spawn_count(&counter) >= 1);
8229 svc.shutdown().await;
8230 }
8231
8232 #[tokio::test]
8233 async fn rate_limit_poller_survives_a_failing_gh() {
8234 let svc = WorktreesService::new();
8238 svc.registry.set_polling("rust-works", "omni-dev", true);
8239 svc.start_rate_limit_poller_with(
8240 Duration::from_millis(20),
8241 PathBuf::from("/no/such/gh/xyzzy"),
8242 );
8243 tokio::time::sleep(Duration::from_millis(150)).await;
8245 assert!(svc.rate_limit_cache.get().is_none());
8246 assert!(
8247 svc.rate_limit_poller
8248 .lock()
8249 .unwrap_or_else(PoisonError::into_inner)
8250 .is_some(),
8251 "the loop must survive a failing gh, not panic out"
8252 );
8253 svc.shutdown().await;
8254 }
8255
8256 #[test]
8257 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8258 let svc = WorktreesService::new();
8259 let items = svc.menu().items;
8261 assert!(
8262 !items
8263 .iter()
8264 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8265 "no github line before the first poll"
8266 );
8267
8268 svc.rate_limit_cache.replace(RateLimitSnapshot {
8270 graphql: Some(rl_resource(82)),
8271 core: Some(rl_resource(3)),
8272 search: None,
8273 });
8274 let items = svc.menu().items;
8275 assert!(
8276 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8277 "expected the github line first, got {items:?}"
8278 );
8279 assert!(
8280 matches!(items.get(1), Some(MenuItem::Separator)),
8281 "expected a separator after the github line"
8282 );
8283 }
8284
8285 #[tokio::test]
8286 #[allow(clippy::await_holding_lock)]
8288 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8289 let dir = tempfile::tempdir().unwrap();
8290 github_repo(dir.path());
8291 let bin_dir = tempfile::tempdir().unwrap();
8292 let (fake, _shim) = fake_gh(
8295 bin_dir.path(),
8296 r#"{"data":{"r0":{"b0":{
8297 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8298 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8299 ]}}},
8300 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8301 }}}}"#,
8302 );
8303 let svc = WorktreesService::new();
8304 svc.handle(
8305 "register",
8306 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8307 )
8308 .await
8309 .unwrap();
8310 svc.registry.set_polling("rust-works", "omni-dev", true);
8313 svc.start_pr_poller_with(
8314 Duration::from_millis(50),
8315 Duration::from_millis(10),
8316 fake.clone(),
8317 );
8318
8319 let badge = tokio::time::timeout(Duration::from_secs(30), async {
8323 loop {
8324 if let Some(PrResolution::Pr(badge)) =
8325 svc.pr_cache.get("rust-works", "omni-dev", "main")
8326 {
8327 return badge;
8328 }
8329 tokio::time::sleep(Duration::from_millis(25)).await;
8330 }
8331 })
8332 .await
8333 .expect("poller should resolve a badge through the fake gh");
8334 assert_eq!(badge.number, 1337);
8335 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8336
8337 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8339 assert_eq!(wt["pr"]["number"], json!(1337));
8340
8341 svc.shutdown().await;
8343 let generation = svc.registry.change_generation();
8344 tokio::time::sleep(Duration::from_millis(120)).await;
8345 assert_eq!(
8346 svc.registry.change_generation(),
8347 generation,
8348 "no bumps after shutdown"
8349 );
8350 }
8351
8352 #[tokio::test]
8353 #[allow(clippy::await_holding_lock)]
8355 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8356 let dir = tempfile::tempdir().unwrap();
8360 github_repo(dir.path());
8361 let bin_dir = tempfile::tempdir().unwrap();
8362 let (fake, _shim) = fake_gh(
8363 bin_dir.path(),
8364 r#"{"data":{
8365 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8366 "resetAt":"2026-07-21T16:00:00Z"},
8367 "r0":{"b0":{
8368 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8369 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8370 ]}}},
8371 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8372 }}
8373 }}"#,
8374 );
8375 let svc = WorktreesService::new();
8376 svc.handle(
8377 "register",
8378 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8379 )
8380 .await
8381 .unwrap();
8382 svc.registry.set_polling("rust-works", "omni-dev", true);
8383 svc.start_pr_poller_with(
8386 Duration::from_millis(50),
8387 Duration::from_millis(10),
8388 fake.clone(),
8389 );
8390
8391 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8392 loop {
8393 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8394 return g;
8395 }
8396 tokio::time::sleep(Duration::from_millis(25)).await;
8397 }
8398 })
8399 .await
8400 .expect("the PR poll should fold its budget into the cache");
8401 assert_eq!(graphql.used, 123);
8402 assert_eq!(graphql.limit, 5000);
8403 assert_eq!(graphql.remaining, 4877);
8404 svc.shutdown().await;
8405 }
8406
8407 #[tokio::test]
8408 #[allow(clippy::await_holding_lock)]
8410 async fn pr_poll_counts_every_gh_call_exactly_once() {
8411 let dir = tempfile::tempdir().unwrap();
8418 github_repo(dir.path());
8419 let bin_dir = tempfile::tempdir().unwrap();
8420 let (fake, _shim, counter) = counting_fake_gh(
8421 bin_dir.path(),
8422 r#"{"data":{"r0":{"b0":{
8423 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8424 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8425 ]}}},
8426 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8427 }}}}"#,
8428 );
8429 let log = bin_dir.path().join("log.jsonl");
8430 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8431
8432 let svc = WorktreesService::new();
8433 svc.handle(
8434 "register",
8435 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8436 )
8437 .await
8438 .unwrap();
8439 svc.registry.set_polling("rust-works", "omni-dev", true);
8440 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8441
8442 tokio::time::timeout(Duration::from_secs(30), async {
8444 loop {
8445 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8446 return;
8447 }
8448 tokio::time::sleep(Duration::from_millis(25)).await;
8449 }
8450 })
8451 .await
8452 .expect("poller should fetch through the fake gh");
8453
8454 svc.shutdown().await;
8456 let spawns = gh_spawn_count(&counter);
8457 let counted = counted_gh_records(&log);
8458 std::env::remove_var("OMNI_DEV_LOG_FILE");
8459 assert!(
8460 spawns >= 1,
8461 "the poll should have spent at least one gh call"
8462 );
8463 assert_eq!(
8464 counted, spawns,
8465 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8466 );
8467 }
8468
8469 #[tokio::test]
8470 #[allow(clippy::await_holding_lock)]
8472 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8473 let dir_a = tempfile::tempdir().unwrap();
8478 let dir_b = tempfile::tempdir().unwrap();
8479 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();
8482 let (fake, _shim, counter) = counting_fake_gh(
8484 bin_dir.path(),
8485 r#"{"data":{
8486 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8487 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8488 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8489 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8490 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8491 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8492 }}"#,
8493 );
8494 let svc = WorktreesService::new();
8495 svc.registry.set_polling("rust-works", "omni-dev", true);
8498 svc.registry.set_polling("rust-works", "other-repo", true);
8499 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8503 svc.handle(
8505 "register",
8506 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8507 )
8508 .await
8509 .unwrap();
8510 tokio::time::sleep(Duration::from_millis(50)).await;
8514 svc.handle(
8515 "register",
8516 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8517 )
8518 .await
8519 .unwrap();
8520
8521 tokio::time::timeout(Duration::from_secs(30), async {
8524 loop {
8525 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8526 let b = svc
8527 .pr_cache
8528 .get("rust-works", "other-repo", "main")
8529 .is_some();
8530 if a && b {
8531 return;
8532 }
8533 tokio::time::sleep(Duration::from_millis(25)).await;
8534 }
8535 })
8536 .await
8537 .expect("the debounced fetch should resolve both repos");
8538
8539 svc.shutdown().await;
8540 assert_eq!(
8541 gh_spawn_count(&counter),
8542 1,
8543 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8544 );
8545 }
8546
8547 #[tokio::test]
8548 #[allow(clippy::await_holding_lock)]
8550 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8551 let dir = tempfile::tempdir().unwrap();
8556 github_repo(dir.path());
8557 let bin_dir = tempfile::tempdir().unwrap();
8558 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8559 let svc = WorktreesService::new();
8560 svc.registry.set_polling("rust-works", "omni-dev", true);
8561 svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8565 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8566 svc.handle("register", register.clone()).await.unwrap();
8567 let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8577 loop {
8578 tokio::time::sleep(Duration::from_millis(25)).await;
8579 svc.handle("register", register.clone()).await.unwrap();
8580 if gh_spawn_count(&counter) >= 1 {
8581 return;
8582 }
8583 }
8584 })
8585 .await;
8586 svc.shutdown().await;
8587 forced_mid_drip.expect(
8588 "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8589 );
8590 }
8591
8592 #[tokio::test]
8593 #[allow(clippy::await_holding_lock)]
8595 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8596 let dir = tempfile::tempdir().unwrap();
8600 let repo = github_repo(dir.path());
8601 let head = repo.head().unwrap().target().unwrap().to_string();
8602 let bin_dir = tempfile::tempdir().unwrap();
8603 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8606
8607 let cache_path = bin_dir.path().join("pr-cache.json");
8611 let target = PrTarget {
8612 owner: "rust-works".into(),
8613 name: "omni-dev".into(),
8614 branch: "main".into(),
8615 };
8616 let prefs = pr_cache_prefs_from(
8617 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8618 &[watch("main", None)],
8619 Utc::now(),
8620 );
8621 write_pr_cache(&cache_path, &prefs).unwrap();
8622
8623 let svc = WorktreesService::new();
8624 svc.load_pr_cache(cache_path);
8625 svc.registry.set_polling("rust-works", "omni-dev", true);
8626 svc.handle(
8627 "register",
8628 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8629 )
8630 .await
8631 .unwrap();
8632 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8635
8636 let number = tokio::time::timeout(Duration::from_secs(30), async {
8638 loop {
8639 let tree = svc.handle("tree", Value::Null).await.unwrap();
8640 if let Some(n) = repos_of(&tree)
8641 .first()
8642 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8643 {
8644 return n;
8645 }
8646 tokio::time::sleep(Duration::from_millis(25)).await;
8647 }
8648 })
8649 .await
8650 .expect("the restored badge should render from the warm cache");
8651 assert_eq!(number, 1337);
8652
8653 tokio::time::sleep(Duration::from_millis(300)).await;
8655 svc.shutdown().await;
8656 assert_eq!(
8657 gh_spawn_count(&counter),
8658 0,
8659 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8660 );
8661 }
8662
8663 #[tokio::test]
8664 #[allow(clippy::await_holding_lock)]
8666 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8667 let dir = tempfile::tempdir().unwrap();
8672 github_repo(dir.path());
8673 let bin_dir = tempfile::tempdir().unwrap();
8674 let (fake, _shim) = fake_gh(
8675 bin_dir.path(),
8676 r#"{"data":{"r0":{"b0":{
8677 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8678 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8679 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8680 }}}}"#,
8681 );
8682 let svc = WorktreesService::new();
8683 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8686 svc.load_pr_cache(cache_path.clone());
8687 svc.registry.set_polling("rust-works", "omni-dev", true);
8688 svc.handle(
8689 "register",
8690 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8691 )
8692 .await
8693 .unwrap();
8694 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8695
8696 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8699 loop {
8700 if let Ok(bytes) = std::fs::read(&cache_path) {
8701 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8702 if !prefs.entries.is_empty() {
8703 return prefs;
8704 }
8705 }
8706 }
8707 tokio::time::sleep(Duration::from_millis(25)).await;
8708 }
8709 })
8710 .await
8711 .expect("a successful resolve should persist the cache file");
8712 svc.shutdown().await;
8713
8714 assert_eq!(prefs.entries[0].target.branch, "main");
8715 assert!(
8716 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8717 "{:?}",
8718 prefs.entries[0].resolution
8719 );
8720 assert_eq!(
8721 prefs.watched,
8722 vec![PersistedWatch {
8723 target: prefs.entries[0].target.clone(),
8724 upstream_sha: None
8725 }]
8726 );
8727 assert!(
8728 prefs.polled_at.is_some(),
8729 "the poll time is what ages the next warm start"
8730 );
8731 }
8732
8733 #[tokio::test]
8734 #[allow(clippy::await_holding_lock)]
8736 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8737 let bin_dir = tempfile::tempdir().unwrap();
8741 let (fake, _shim, counter) = counting_fake_gh(
8742 bin_dir.path(),
8743 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8744 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8745 );
8746 let svc = WorktreesService::new();
8747
8748 let prs = svc
8749 .open_prs_with("rust-works", "omni-dev", fake.clone())
8750 .await
8751 .expect("gh pr list should resolve");
8752 assert_eq!(prs.len(), 1);
8753 assert_eq!(prs[0]["number"], json!(42));
8754 assert_eq!(prs[0]["url"], json!("http://x/42"));
8755 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8756
8757 let again = svc
8759 .open_prs_with("rust-works", "omni-dev", fake.clone())
8760 .await
8761 .expect("cache hit should resolve");
8762 assert_eq!(again, prs);
8763 assert_eq!(
8764 gh_spawn_count(&counter),
8765 1,
8766 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8767 );
8768
8769 let reply = svc
8771 .handle(
8772 "open-prs",
8773 json!({ "owner": "rust-works", "name": "omni-dev" }),
8774 )
8775 .await
8776 .expect("open-prs op should route");
8777 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8778 assert!(svc
8779 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
8780 .await
8781 .is_err());
8782 }
8783
8784 #[test]
8785 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8786 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8791 assert!(
8792 err.to_string().contains("is the GitHub CLI installed"),
8793 "{err:#}"
8794 );
8795
8796 let bin_dir = tempfile::tempdir().unwrap();
8797 let _guard = shim_lock();
8798 let failing = bin_dir.path().join("fake-gh-fails");
8799 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8800 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8801 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8802 assert!(err.to_string().contains("boom"), "{err:#}");
8803
8804 let object = bin_dir.path().join("fake-gh-object");
8805 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8806 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8807 assert!(
8808 err.to_string().contains("did not return a JSON array"),
8809 "{err:#}"
8810 );
8811 }
8812
8813 #[tokio::test]
8814 #[allow(clippy::await_holding_lock)]
8816 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8817 let dir = tempfile::tempdir().unwrap();
8823 github_repo(dir.path());
8824 let bin_dir = tempfile::tempdir().unwrap();
8825 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8826
8827 let svc = WorktreesService::new();
8828 *svc.pr_warm_start
8832 .lock()
8833 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8834 watched: vec![],
8835 polled_at: Utc::now(),
8836 });
8837 svc.rate_limit_cache.replace(RateLimitSnapshot {
8839 graphql: Some(rl_resource(90)),
8840 core: Some(rl_resource(3)),
8841 search: None,
8842 });
8843 svc.registry.set_polling("rust-works", "omni-dev", true);
8844 svc.handle(
8845 "register",
8846 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8847 )
8848 .await
8849 .unwrap();
8850 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8853
8854 tokio::time::sleep(Duration::from_millis(300)).await;
8856 svc.shutdown().await;
8857 assert_eq!(
8858 gh_spawn_count(&counter),
8859 0,
8860 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8861 );
8862 }
8863
8864 #[tokio::test]
8865 #[allow(clippy::await_holding_lock)]
8867 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8868 let dir = tempfile::tempdir().unwrap();
8871 github_repo(dir.path());
8872 let bin_dir = tempfile::tempdir().unwrap();
8873 let (fake, _shim) = fake_gh(
8874 bin_dir.path(),
8875 r#"{"data":{"r0":{"b0":{
8876 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8877 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8878 ]}}},
8879 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8880 }}}}"#,
8881 );
8882 let svc = WorktreesService::new();
8883 svc.handle(
8884 "register",
8885 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8886 )
8887 .await
8888 .unwrap();
8889 svc.registry.set_polling("rust-works", "omni-dev", true);
8892 svc.start_pr_poller_with(
8893 Duration::from_millis(50),
8894 Duration::from_millis(10),
8895 fake.clone(),
8896 );
8897
8898 tokio::time::timeout(Duration::from_secs(30), async {
8899 loop {
8900 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8901 return;
8902 }
8903 tokio::time::sleep(Duration::from_millis(25)).await;
8904 }
8905 })
8906 .await
8907 .expect("poller should resolve a badge through the fake gh");
8908 let settled = svc.registry.change_generation();
8911 tokio::time::sleep(Duration::from_millis(150)).await;
8912 assert_eq!(
8913 svc.registry.change_generation(),
8914 settled,
8915 "an unchanged poll must not bump the change-notify"
8916 );
8917 svc.shutdown().await;
8918 }
8919
8920 #[tokio::test]
8921 #[allow(clippy::await_holding_lock)]
8923 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8924 let dir = tempfile::tempdir().unwrap();
8929 github_repo(dir.path());
8930 let bin_dir = tempfile::tempdir().unwrap();
8931 let (fake, _shim) = fake_gh(
8932 bin_dir.path(),
8933 r#"{"data":{"r0":{"b0":{
8934 "target":{"oid":"abc","statusCheckRollup":null},
8935 "associatedPullRequests":{"nodes":[]}
8936 }}}}"#,
8937 );
8938 let svc = WorktreesService::new();
8939 svc.handle(
8940 "register",
8941 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8942 )
8943 .await
8944 .unwrap();
8945 svc.registry.set_polling("rust-works", "omni-dev", true);
8948 svc.start_pr_poller_with(
8949 Duration::from_millis(50),
8950 Duration::from_millis(10),
8951 fake.clone(),
8952 );
8953
8954 tokio::time::timeout(Duration::from_secs(30), async {
8955 loop {
8956 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8957 return;
8958 }
8959 tokio::time::sleep(Duration::from_millis(25)).await;
8960 }
8961 })
8962 .await
8963 .expect("poller should resolve the negative through the fake gh");
8964
8965 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8967 assert_eq!(wt["pr_none"], json!(true));
8968 assert!(wt.get("pr").is_none(), "{wt:?}");
8969
8970 let settled = svc.registry.change_generation();
8972 tokio::time::sleep(Duration::from_millis(150)).await;
8973 assert_eq!(
8974 svc.registry.change_generation(),
8975 settled,
8976 "an unchanged negative must not bump the change-notify"
8977 );
8978 svc.shutdown().await;
8979 }
8980
8981 #[test]
8982 fn sync_indicator_formats_only_with_upstream() {
8983 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8984 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8985 assert_eq!(sync_indicator(None, None), None);
8986 assert_eq!(sync_indicator(Some(1), None), None);
8988 }
8989
8990 #[tokio::test]
8991 async fn list_enriches_entries_with_git_status() {
8992 let dir = tempfile::tempdir().unwrap();
8993 let repo = init_repo(dir.path());
8994 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8995 repo.set_head("refs/heads/main").unwrap();
8996
8997 let svc = WorktreesService::new();
8998 svc.handle(
8999 "register",
9000 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9001 )
9002 .await
9003 .unwrap();
9004 let payload = svc.handle("list", Value::Null).await.unwrap();
9005 let windows = windows_of(&payload);
9006 assert_eq!(windows.len(), 1);
9007 assert_eq!(
9008 windows[0].get("branch").and_then(Value::as_str),
9009 Some("main")
9010 );
9011 assert!(windows[0].get("ahead").is_none());
9013 assert!(windows[0].get("behind").is_none());
9014 assert_eq!(
9016 windows[0].get("main_repo").and_then(Value::as_str),
9017 dir.path().file_name().and_then(|n| n.to_str())
9018 );
9019
9020 let plain = tempfile::tempdir().unwrap();
9022 svc.handle(
9023 "register",
9024 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9025 )
9026 .await
9027 .unwrap();
9028 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9029 let w2 = windows
9030 .iter()
9031 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9032 .unwrap();
9033 assert!(w2.get("branch").is_none());
9034 assert!(w2.get("main_repo").is_none());
9035 }
9036
9037 #[test]
9038 fn window_label_prefers_git_branch_over_title() {
9039 let dir = tempfile::tempdir().unwrap();
9040 let repo = init_repo(dir.path());
9041 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9042 repo.set_head("refs/heads/main").unwrap();
9043 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9044 let entry = WindowEntry {
9045 key: "k".to_string(),
9046 folders: vec![dir.path().to_path_buf()],
9047 repo: Some("companion-repo".to_string()),
9050 title: Some("ignored title".to_string()),
9051 pid: None,
9052 last_seen: Utc::now(),
9053 };
9054 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9056 }
9057
9058 #[tokio::test]
9059 async fn list_includes_ahead_behind_for_tracking_branch() {
9060 let dir = tempfile::tempdir().unwrap();
9061 let _repo = diverging_repo(dir.path());
9062
9063 let svc = WorktreesService::new();
9064 svc.handle(
9065 "register",
9066 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9067 )
9068 .await
9069 .unwrap();
9070 let payload = svc.handle("list", Value::Null).await.unwrap();
9071 let windows = windows_of(&payload);
9072 assert_eq!(
9074 windows[0].get("branch").and_then(Value::as_str),
9075 Some("main")
9076 );
9077 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9078 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9079 }
9080
9081 #[test]
9082 fn window_label_includes_sync_for_tracking_branch() {
9083 let dir = tempfile::tempdir().unwrap();
9084 let _repo = diverging_repo(dir.path());
9085 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9086 let entry = WindowEntry {
9087 key: "k".to_string(),
9088 folders: vec![dir.path().to_path_buf()],
9089 repo: Some("companion-repo".to_string()),
9090 title: None,
9091 pid: None,
9092 last_seen: Utc::now(),
9093 };
9094 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9096 }
9097
9098 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9102 let commit = repo.find_commit(base).unwrap();
9103 repo.branch(branch, &commit, false).unwrap();
9104 let reference = repo
9105 .find_reference(&format!("refs/heads/{branch}"))
9106 .unwrap();
9107 let mut opts = git2::WorktreeAddOptions::new();
9108 opts.reference(Some(&reference));
9109 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9110 }
9111
9112 #[test]
9113 fn git_status_marks_linked_worktree_and_names_parent_repo() {
9114 let main_dir = tempfile::tempdir().unwrap();
9115 let repo = init_repo(main_dir.path());
9116 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9117 repo.set_head("refs/heads/main").unwrap();
9118
9119 let wt_parent = tempfile::tempdir().unwrap();
9122 let wt_path = wt_parent.path().join("feature-wt");
9123 add_worktree(&repo, a, &wt_path, "feature");
9124
9125 let status = git_status(&wt_path);
9126 assert!(status.is_worktree);
9127 assert_eq!(status.branch.as_deref(), Some("feature"));
9128 assert_eq!(
9130 status.main_repo.as_deref(),
9131 main_dir.path().file_name().and_then(|n| n.to_str())
9132 );
9133
9134 let main_status = git_status(main_dir.path());
9136 assert!(!main_status.is_worktree);
9137 assert_eq!(main_status.main_repo, status.main_repo);
9138 }
9139
9140 #[test]
9141 fn window_label_marks_worktree_with_fork_glyph() {
9142 let main_dir = tempfile::tempdir().unwrap();
9143 let repo = init_repo(main_dir.path());
9144 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9145 repo.set_head("refs/heads/main").unwrap();
9146 let wt_parent = tempfile::tempdir().unwrap();
9147 let wt_path = wt_parent.path().join("feature-wt");
9148 add_worktree(&repo, a, &wt_path, "feature");
9149
9150 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9151 let entry = WindowEntry {
9152 key: "k".to_string(),
9153 folders: vec![wt_path],
9154 repo: Some("feature-wt".to_string()),
9155 title: None,
9156 pid: None,
9157 last_seen: Utc::now(),
9158 };
9159 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9162 }
9163
9164 #[test]
9165 fn main_repo_name_derives_from_common_dir() {
9166 assert_eq!(
9168 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9169 Some("omni-dev")
9170 );
9171 assert_eq!(
9173 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9174 Some("omni-dev")
9175 );
9176 assert_eq!(
9178 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9179 Some("omni-dev")
9180 );
9181 assert_eq!(main_repo_name(Path::new("/.git")), None);
9183 }
9184
9185 fn repos_of(payload: &Value) -> Vec<Value> {
9190 payload
9191 .get("repos")
9192 .and_then(Value::as_array)
9193 .expect("repos array")
9194 .clone()
9195 }
9196
9197 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9198 Some(GithubIdentity {
9199 owner: owner.to_string(),
9200 name: name.to_string(),
9201 })
9202 }
9203
9204 #[test]
9205 fn github_identity_parses_supported_forms() {
9206 assert_eq!(
9208 github_identity("https://github.com/rust-works/omni-dev.git"),
9209 github("rust-works", "omni-dev")
9210 );
9211 assert_eq!(
9212 github_identity("https://github.com/rust-works/omni-dev"),
9213 github("rust-works", "omni-dev")
9214 );
9215 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9216 assert_eq!(
9218 github_identity("git@github.com:rust-works/omni-dev.git"),
9219 github("rust-works", "omni-dev")
9220 );
9221 assert_eq!(
9222 github_identity("ssh://git@github.com/o/r.git"),
9223 github("o", "r")
9224 );
9225 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9226 assert_eq!(
9228 github_identity(" https://github.com/o/r/ "),
9229 github("o", "r")
9230 );
9231 }
9232
9233 #[test]
9234 fn github_identity_rejects_non_github_and_malformed() {
9235 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9237 assert_eq!(github_identity("git@example.com:o/r.git"), None);
9238 assert_eq!(github_identity("https://github.com/onlyowner"), None);
9240 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9241 assert_eq!(github_identity("https://github.com/"), None);
9242 assert_eq!(github_identity("not a url"), None);
9244 }
9245
9246 #[test]
9247 fn remote_github_identity_reads_origin_then_falls_back() {
9248 let dir = tempfile::tempdir().unwrap();
9249 let repo = init_repo(dir.path());
9250 assert_eq!(remote_github_identity(&repo), None);
9252 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9254 assert_eq!(remote_github_identity(&repo), None);
9255 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9257 .unwrap();
9258 assert_eq!(
9259 remote_github_identity(&repo),
9260 github("rust-works", "omni-dev")
9261 );
9262
9263 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9266 .unwrap();
9267 repo.remote("upstream", "https://github.com/other/proj.git")
9268 .unwrap();
9269 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9270 }
9271
9272 #[tokio::test]
9273 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9274 let svc = WorktreesService::new();
9275 assert_eq!(
9277 svc.handle("tree", Value::Null).await.unwrap(),
9278 json!({ "repos": [], "show_closed": true })
9279 );
9280 let plain = tempfile::tempdir().unwrap();
9282 svc.handle(
9283 "register",
9284 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9285 )
9286 .await
9287 .unwrap();
9288 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9289 }
9290
9291 #[tokio::test]
9292 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9293 let main_dir = tempfile::tempdir().unwrap();
9294 let repo = init_repo(main_dir.path());
9295 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9296 repo.set_head("refs/heads/main").unwrap();
9297 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9299 .unwrap();
9300
9301 let wt_parent = tempfile::tempdir().unwrap();
9304 let wt_path = wt_parent.path().join("feature-wt");
9305 add_worktree(&repo, a, &wt_path, "feature");
9306
9307 let svc = WorktreesService::new();
9308 svc.handle(
9311 "register",
9312 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9313 )
9314 .await
9315 .unwrap();
9316 svc.handle(
9317 "register",
9318 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9319 )
9320 .await
9321 .unwrap();
9322
9323 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9324 assert_eq!(
9325 repos.len(),
9326 1,
9327 "two worktrees of one repo dedupe: {repos:?}"
9328 );
9329 let repo0 = &repos[0];
9330 assert_eq!(
9332 repo0.get("main_repo").and_then(Value::as_str),
9333 main_dir.path().file_name().and_then(|n| n.to_str())
9334 );
9335 assert_eq!(
9336 repo0.pointer("/github/owner").and_then(Value::as_str),
9337 Some("rust-works")
9338 );
9339 assert_eq!(
9340 repo0.pointer("/github/name").and_then(Value::as_str),
9341 Some("omni-dev")
9342 );
9343 assert!(repo0.get("root").and_then(Value::as_str).is_some());
9344
9345 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9346 assert_eq!(worktrees.len(), 2);
9347 let main_wt = &worktrees[0];
9349 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9350 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9351 assert_eq!(
9352 main_wt.get("window_key").and_then(Value::as_str),
9353 Some("wm")
9354 );
9355 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9356 let linked = &worktrees[1];
9358 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9359 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9360 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9361 assert_eq!(
9362 linked.get("branch").and_then(Value::as_str),
9363 Some("feature")
9364 );
9365 }
9366
9367 #[tokio::test]
9368 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9369 let main_dir = tempfile::tempdir().unwrap();
9370 let repo = init_repo(main_dir.path());
9371 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9372 repo.set_head("refs/heads/main").unwrap();
9373 let wt_parent = tempfile::tempdir().unwrap();
9375 let wt_path = wt_parent.path().join("feature-wt");
9376 add_worktree(&repo, a, &wt_path, "feature");
9377
9378 let svc = WorktreesService::new();
9379 svc.handle(
9381 "register",
9382 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9383 )
9384 .await
9385 .unwrap();
9386
9387 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9388 assert_eq!(repos.len(), 1);
9389 assert!(repos[0].get("github").is_none(), "no remote → no github");
9390 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9391 let linked = worktrees
9392 .iter()
9393 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9394 .expect("the linked worktree");
9395 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9397 assert!(linked.get("window_key").is_none());
9398 }
9399
9400 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9406 let main_dir = tempfile::tempdir().unwrap();
9407 let repo = init_repo(main_dir.path());
9408 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9409 repo.set_head("refs/heads/trunk").unwrap();
9410 let wt_parent = tempfile::tempdir().unwrap();
9411 let wt_path = wt_parent.path().join("feature-wt");
9412 add_worktree(&repo, a, &wt_path, "feature");
9413 (main_dir, wt_parent, wt_path)
9414 }
9415
9416 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9420 {
9421 let main_dir = tempfile::tempdir().unwrap();
9422 let repo = init_repo(main_dir.path());
9423 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9424 repo.set_head("refs/heads/trunk").unwrap();
9425 let wt_parent = tempfile::tempdir().unwrap();
9426 let first = wt_parent.path().join("first-wt");
9427 let second = wt_parent.path().join("second-wt");
9428 add_worktree(&repo, a, &first, "first");
9429 add_worktree(&repo, a, &second, "second");
9430 (main_dir, wt_parent, first, second)
9431 }
9432
9433 #[tokio::test]
9434 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9435 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9436 let svc = Arc::new(WorktreesService::new());
9437
9438 let close = |path: PathBuf| {
9448 let svc = svc.clone();
9449 async move {
9450 svc.handle(
9451 "close",
9452 json!({ "path": path, "remove": true, "confirmed": true }),
9453 )
9454 .await
9455 }
9456 };
9457 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9458
9459 assert_eq!(a.unwrap(), json!({ "removed": true }));
9460 assert_eq!(b.unwrap(), json!({ "removed": true }));
9461 assert!(!first.exists());
9462 assert!(!second.exists());
9463 let repo = Repository::open(main_dir.path()).unwrap();
9466 assert!(repo.worktrees().unwrap().is_empty());
9467 }
9468
9469 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9476 let repo = init_repo(dir);
9477 let refname = format!("refs/heads/{branch}");
9478 let head = empty_commit(&repo, Some(&refname), &[], "A");
9479 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9480 .unwrap();
9481 repo.set_head(&refname).unwrap();
9482 let mut cfg = repo.config().unwrap();
9483 cfg.set_str("remote.origin.url", url).unwrap();
9484 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9485 .unwrap();
9486 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9487 .unwrap();
9488 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9489 .unwrap();
9490 repo
9491 }
9492
9493 #[test]
9494 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9495 let dir = tempfile::tempdir().unwrap();
9496 let _repo = pushed_github_repo(
9497 dir.path(),
9498 "https://github.com/rust-works/omni-dev.git",
9499 "feature",
9500 );
9501 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9502 assert_eq!(
9503 ok.target,
9504 PrTarget {
9505 owner: "rust-works".into(),
9506 name: "omni-dev".into(),
9507 branch: "feature".into(),
9508 }
9509 );
9510 assert!(!ok.head_sha.is_empty());
9511 }
9512
9513 #[test]
9514 fn evaluate_local_skips_an_unborn_head() {
9515 let dir = tempfile::tempdir().unwrap();
9516 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9518 }
9519
9520 #[test]
9521 fn evaluate_local_skips_a_branch_with_no_upstream() {
9522 let dir = tempfile::tempdir().unwrap();
9523 let repo = init_repo(dir.path());
9524 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9525 repo.set_head("refs/heads/main").unwrap();
9526 repo.config()
9528 .unwrap()
9529 .set_str("remote.origin.url", "https://github.com/o/r.git")
9530 .unwrap();
9531 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9532 }
9533
9534 #[test]
9535 fn evaluate_local_skips_unpushed_local_commits() {
9536 let dir = tempfile::tempdir().unwrap();
9537 let repo = init_repo(dir.path());
9538 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9539 let a_commit = repo.find_commit(a).unwrap();
9540 repo.reference("refs/remotes/origin/main", a, true, "o")
9542 .unwrap();
9543 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9544 drop(a_commit);
9545 repo.set_head("refs/heads/main").unwrap();
9546 let mut cfg = repo.config().unwrap();
9547 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9548 .unwrap();
9549 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9552 .unwrap();
9553 cfg.set_str("branch.main.remote", "origin").unwrap();
9554 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9555 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9556 }
9557
9558 #[test]
9559 fn evaluate_local_skips_a_detached_head() {
9560 let dir = tempfile::tempdir().unwrap();
9561 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9562 let head = repo.head().unwrap().target().unwrap();
9563 repo.set_head_detached(head).unwrap();
9564 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9565 }
9566
9567 #[test]
9568 fn evaluate_local_skips_a_non_github_remote() {
9569 let dir = tempfile::tempdir().unwrap();
9570 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9571 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9572 }
9573
9574 #[test]
9575 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9576 assert_eq!(
9580 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9581 .unwrap_err()
9582 .kind,
9583 "not-a-repo"
9584 );
9585 }
9586
9587 #[test]
9588 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9589 let req = MergeQueueRequest {
9592 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9593 requester_key: Some("win-9".into()),
9594 check: true,
9595 confirmed: false,
9596 };
9597 let logs = capture_info(|| log_merge_check(&req, 1, 1));
9598 assert!(logs.contains("merge-queue check"), "{logs}");
9599 assert!(logs.contains("win-9"), "{logs}");
9600 assert!(logs.contains("requested=2"), "{logs}");
9601 assert!(logs.contains("eligible=1"), "{logs}");
9602 }
9603
9604 #[test]
9605 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9606 let req = MergeQueueRequest {
9608 paths: vec![PathBuf::from("/a")],
9609 requester_key: None,
9610 check: false,
9611 confirmed: true,
9612 };
9613 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9614 assert!(logs.contains("merge-queue enqueue"), "{logs}");
9615 assert!(logs.contains("queued=2"), "{logs}");
9616 assert!(logs.contains("failed=1"), "{logs}");
9617 }
9618
9619 #[test]
9620 fn evaluate_local_flags_dirty_then_untracked() {
9621 let main_dir = tempfile::tempdir().unwrap();
9623 let repo = init_repo(main_dir.path());
9624 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9625 repo.set_head("refs/heads/main").unwrap();
9626 let wt_parent = tempfile::tempdir().unwrap();
9627 let wt_path = wt_parent.path().join("feature-wt");
9628 add_worktree(&repo, a, &wt_path, "feature");
9629
9630 let clean = evaluate_local(&wt_path).unwrap_err();
9632 assert_ne!(clean.kind, "dirty");
9633 assert_ne!(clean.kind, "untracked");
9634
9635 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9637 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9638
9639 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9641 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9642 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9643 }
9644
9645 #[test]
9646 fn is_conflicting_blocks_only_dirty_and_conflicting() {
9647 assert!(is_conflicting(Some("CONFLICTING")));
9648 assert!(is_conflicting(Some("DIRTY")));
9649 assert!(!is_conflicting(Some("CLEAN")));
9650 assert!(!is_conflicting(Some("BLOCKED")));
9651 assert!(!is_conflicting(Some("UNKNOWN")));
9652 assert!(!is_conflicting(None));
9653 }
9654
9655 #[test]
9656 fn merge_queue_request_parses_batch_and_phase_flags() {
9657 let req: MergeQueueRequest = serde_json::from_value(json!({
9658 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9659 }))
9660 .unwrap();
9661 assert_eq!(req.paths.len(), 2);
9662 assert_eq!(req.requester_key.as_deref(), Some("w1"));
9663 assert!(req.confirmed);
9664 assert!(!req.check);
9665 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9667 assert!(req.paths.is_empty());
9668 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9669 }
9670
9671 #[test]
9672 fn queued_pr_omits_already_queued_when_false() {
9673 let v = serde_json::to_value(QueuedPr {
9674 path: "/a".into(),
9675 number: 5,
9676 already_queued: false,
9677 })
9678 .unwrap();
9679 assert!(v.get("already_queued").is_none(), "{v}");
9680 let v = serde_json::to_value(QueuedPr {
9681 path: "/a".into(),
9682 number: 5,
9683 already_queued: true,
9684 })
9685 .unwrap();
9686 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9687 }
9688
9689 #[tokio::test]
9690 async fn merge_queue_check_on_empty_selection_reports_nothing() {
9691 let svc = WorktreesService::new();
9692 let reply = svc
9693 .handle("merge-queue", json!({ "paths": [], "check": true }))
9694 .await
9695 .unwrap();
9696 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9697 }
9698
9699 #[tokio::test]
9700 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9701 let dir = tempfile::tempdir().unwrap();
9704 let _repo = init_repo(dir.path());
9705 let svc = WorktreesService::new();
9706 let reply = svc
9707 .handle(
9708 "merge-queue",
9709 json!({ "paths": [dir.path()], "check": true }),
9710 )
9711 .await
9712 .unwrap();
9713 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9714 assert_eq!(skipped.len(), 1);
9715 assert_eq!(
9716 skipped[0].get("kind").and_then(Value::as_str),
9717 Some("no-commits")
9718 );
9719 assert!(reply
9720 .get("eligible")
9721 .and_then(Value::as_array)
9722 .unwrap()
9723 .is_empty());
9724 }
9725
9726 fn ready_worktree() -> (tempfile::TempDir, String) {
9730 let dir = tempfile::tempdir().unwrap();
9731 let repo = pushed_github_repo(
9732 dir.path(),
9733 "https://github.com/rust-works/omni-dev.git",
9734 "feature",
9735 );
9736 let head = repo.head().unwrap().target().unwrap().to_string();
9737 (dir, head)
9738 }
9739
9740 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9743 format!(
9744 r#"{{"data":{{"r0":{{"b0":{{
9745 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9746 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9747 ]}}}}}},
9748 "associatedPullRequests":{{"nodes":[{pr}]}}
9749 }}}}}}}}"#
9750 )
9751 }
9752
9753 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9757 let ghdir = tempfile::tempdir().unwrap();
9758 let (bin, _shim) = fake_gh(ghdir.path(), reply);
9759 let paths = vec![head_dir.to_path_buf()];
9760 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9761 if let Some(e) = eligible.first() {
9762 return Ok(e.number);
9763 }
9764 Err(skipped.remove(0).kind)
9765 }
9766
9767 #[test]
9768 fn evaluate_batch_marks_a_ready_pr_eligible() {
9769 let (dir, head) = ready_worktree();
9770 let pr = format!(
9771 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9772 );
9773 assert_eq!(
9774 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9775 Ok(9)
9776 );
9777 }
9778
9779 #[test]
9780 fn evaluate_batch_skips_a_draft_pr() {
9781 let (dir, head) = ready_worktree();
9782 let pr = format!(
9783 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9784 );
9785 assert_eq!(
9786 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9787 Err("draft".to_string())
9788 );
9789 }
9790
9791 #[test]
9792 fn evaluate_batch_skips_a_conflicting_pr() {
9793 let (dir, head) = ready_worktree();
9794 let pr = format!(
9795 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9796 );
9797 assert_eq!(
9798 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9799 Err("conflicting".to_string())
9800 );
9801 }
9802
9803 #[test]
9804 fn evaluate_batch_skips_a_pr_with_failing_checks() {
9805 let (dir, head) = ready_worktree();
9806 let pr = format!(
9807 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9808 );
9809 assert_eq!(
9810 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9811 Err("checks-failing".to_string())
9812 );
9813 }
9814
9815 #[test]
9816 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9817 let (dir, head) = ready_worktree();
9818 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9820 assert_eq!(
9821 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9822 Err("stale".to_string())
9823 );
9824 }
9825
9826 #[test]
9827 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9828 let (dir, head) = ready_worktree();
9829 let reply = format!(
9831 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9832 );
9833 assert_eq!(
9834 network_gate_outcome(dir.path(), &reply),
9835 Err("no-pr".to_string())
9836 );
9837 }
9838
9839 #[test]
9840 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9841 let eligible = vec![
9844 Eligible {
9845 path: PathBuf::from("/wt/a"),
9846 number: 1,
9847 url: "u".into(),
9848 branch: "a".into(),
9849 pr_id: "PR_A".into(),
9850 already_queued: true,
9851 },
9852 Eligible {
9853 path: PathBuf::from("/wt/b"),
9854 number: 2,
9855 url: "u".into(),
9856 branch: "b".into(),
9857 pr_id: "PR_B".into(),
9858 already_queued: false,
9859 },
9860 ];
9861 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9862 assert_eq!(queued.len(), 1);
9863 assert_eq!(queued[0].number, 1);
9864 assert!(queued[0].already_queued);
9865 assert_eq!(failed.len(), 1);
9866 assert_eq!(failed[0].number, 2);
9867 }
9868
9869 #[test]
9870 fn enqueue_eligible_records_a_github_rejection_as_failed() {
9871 let ghdir = tempfile::tempdir().unwrap();
9875 let (bin, _shim) = fake_gh(
9876 ghdir.path(),
9877 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9878 );
9879 let eligible = vec![Eligible {
9880 path: PathBuf::from("/wt/a"),
9881 number: 7,
9882 url: "u".into(),
9883 branch: "a".into(),
9884 pr_id: "PR_A".into(),
9885 already_queued: false,
9886 }];
9887 let (queued, failed) = enqueue_eligible(&bin, eligible);
9888 assert!(queued.is_empty(), "{queued:?}");
9889 assert_eq!(failed.len(), 1);
9890 assert_eq!(failed[0].number, 7);
9891 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9894 }
9895
9896 #[tokio::test]
9897 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9899 let (dir, head) = ready_worktree();
9902 let pr = format!(
9903 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9904 );
9905 let ghdir = tempfile::tempdir().unwrap();
9906 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9907 let svc = WorktreesService::new();
9908 let req = MergeQueueRequest {
9909 paths: vec![dir.path().to_path_buf()],
9910 requester_key: None,
9911 check: true,
9912 confirmed: false,
9913 };
9914 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9918 .await
9919 .unwrap();
9920 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9921 assert_eq!(eligible.len(), 1);
9922 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9923 assert_eq!(
9924 eligible[0].get("branch").and_then(Value::as_str),
9925 Some("feature")
9926 );
9927 }
9928
9929 #[tokio::test]
9930 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9932 let (dir, head) = ready_worktree();
9935 let pr = format!(
9936 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9937 );
9938 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9939 let ghdir = tempfile::tempdir().unwrap();
9940 let guard = shim_lock();
9941 let bin = ghdir.path().join("fake-gh");
9942 write_exec_script(
9943 &bin,
9944 &format!(
9945 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
9946 enqueue =
9947 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9948 ),
9949 );
9950 let svc = WorktreesService::new();
9951 let req = MergeQueueRequest {
9952 paths: vec![dir.path().to_path_buf()],
9953 requester_key: Some("w1".into()),
9954 check: false,
9955 confirmed: true,
9956 };
9957 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9962 .await
9963 .unwrap();
9964 drop(guard);
9965 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9966 assert_eq!(queued.len(), 1, "{reply}");
9967 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9968 assert!(reply
9969 .get("failed")
9970 .and_then(Value::as_array)
9971 .unwrap()
9972 .is_empty());
9973 }
9974
9975 #[tokio::test]
9976 async fn concurrent_closes_overlap_their_heartbeat_waits() {
9977 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9978 let svc = Arc::new(WorktreesService::new());
9979 for (key, path) in [("w2", &first), ("w3", &second)] {
9981 svc.handle("register", json!({ "key": key, "folders": [path] }))
9982 .await
9983 .unwrap();
9984 }
9985
9986 let spawn_close = |path: PathBuf| {
9987 let svc = svc.clone();
9988 tokio::spawn(async move {
9989 svc.handle(
9990 "close",
9991 json!({
9992 "path": path,
9993 "remove": true,
9994 "confirmed": true,
9995 "requester_key": "w1",
9996 }),
9997 )
9998 .await
9999 })
10000 };
10001 let a = spawn_close(first.clone());
10002 let b = spawn_close(second.clone());
10003
10004 for key in ["w2", "w3"] {
10011 let mut saw_close = false;
10012 for _ in 0..400 {
10013 let hb = svc
10014 .handle("heartbeat", json!({ "key": key }))
10015 .await
10016 .unwrap();
10017 if hb.get("close").and_then(Value::as_bool) == Some(true) {
10018 saw_close = true;
10019 break;
10020 }
10021 tokio::time::sleep(Duration::from_millis(5)).await;
10022 }
10023 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10024 }
10025 assert!(
10026 !a.is_finished() && !b.is_finished(),
10027 "neither close can have finished: both windows are still registered"
10028 );
10029
10030 for key in ["w2", "w3"] {
10032 svc.handle("unregister", json!({ "key": key }))
10033 .await
10034 .unwrap();
10035 }
10036 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10037 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10038 assert!(!first.exists());
10039 assert!(!second.exists());
10040 }
10041
10042 #[tokio::test]
10043 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10044 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10045 let svc = WorktreesService::new();
10046 let report = svc
10049 .handle("close", json!({ "path": wt_path, "remove": true }))
10050 .await
10051 .unwrap();
10052 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10053 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10054 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10055 assert!(report
10056 .get("risks")
10057 .and_then(Value::as_array)
10058 .unwrap()
10059 .is_empty());
10060 assert!(wt_path.exists());
10062 }
10063
10064 #[tokio::test]
10065 async fn close_removes_a_clean_linked_worktree() {
10066 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10067 let svc = WorktreesService::new();
10068 let reply = svc
10069 .handle(
10070 "close",
10071 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10072 )
10073 .await
10074 .unwrap();
10075 assert_eq!(reply, json!({ "removed": true }));
10076 assert!(
10077 !wt_path.exists(),
10078 "the worktree directory should be deleted"
10079 );
10080 }
10081
10082 #[derive(Clone, Default)]
10092 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10093
10094 impl std::io::Write for CaptureWriter {
10095 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10096 self.0.lock().unwrap().extend_from_slice(buf);
10097 Ok(buf.len())
10098 }
10099 fn flush(&mut self) -> std::io::Result<()> {
10100 Ok(())
10101 }
10102 }
10103
10104 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10105 type Writer = Self;
10106 fn make_writer(&'a self) -> Self::Writer {
10107 self.clone()
10108 }
10109 }
10110
10111 fn capture_info(f: impl FnOnce()) -> String {
10114 let writer = CaptureWriter::default();
10115 let subscriber = tracing_subscriber::fmt()
10116 .with_max_level(tracing::Level::INFO)
10117 .with_ansi(false)
10118 .with_writer(writer.clone())
10119 .finish();
10120 tracing::subscriber::with_default(subscriber, f);
10121 let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10122 logs
10123 }
10124
10125 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10129 RebaseRequest {
10130 paths,
10131 requester_key: None,
10132 check: false,
10133 confirmed: false,
10134 keep_conflicts: false,
10135 autostash: false,
10136 onto: None,
10137 }
10138 }
10139
10140 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10148 let main_dir = tempfile::tempdir().unwrap();
10149 let repo = init_repo(main_dir.path());
10150 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10151 repo.set_head("refs/heads/main").unwrap();
10152 let wt_parent = tempfile::tempdir().unwrap();
10153 let wt_path = wt_parent.path().join("feature-wt");
10154 add_worktree(&repo, base, &wt_path, "feature");
10155 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10157 (main_dir, wt_parent, wt_path)
10158 }
10159
10160 #[tokio::test]
10161 async fn rebase_with_refuses_an_empty_selection() {
10162 let svc = WorktreesService::new();
10164 let err = svc
10165 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10166 .await
10167 .unwrap_err()
10168 .to_string();
10169 assert!(err.contains("at least one path"), "{err}");
10170 }
10171
10172 #[tokio::test]
10173 async fn rebase_with_phase_one_reports_without_rebasing() {
10174 let (_main, _parent, wt) = behind_worktree();
10175 let before = Repository::open(&wt).unwrap().head().unwrap().target();
10176
10177 let svc = WorktreesService::new();
10178 let reply = svc
10179 .rebase_with(
10180 RebaseRequest {
10181 check: true,
10182 onto: Some("main".into()),
10183 ..rebase_req(vec![wt.clone()])
10184 },
10185 crate::git::resolve_git_binary(),
10186 )
10187 .await
10188 .unwrap();
10189
10190 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10191 assert_eq!(worktrees.len(), 1, "{reply}");
10192 assert_eq!(
10193 worktrees[0].get("status").and_then(Value::as_str),
10194 Some("would-rebase"),
10195 "{reply}"
10196 );
10197 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10199 assert_eq!(fetches.len(), 1);
10200 assert_eq!(
10201 fetches[0].get("fetched").and_then(Value::as_bool),
10202 Some(false)
10203 );
10204 assert_eq!(
10205 Repository::open(&wt).unwrap().head().unwrap().target(),
10206 before,
10207 "phase 1 must not move the branch"
10208 );
10209 }
10210
10211 #[tokio::test]
10212 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10213 let (_main, _parent, wt) = behind_worktree();
10214 let svc = WorktreesService::new();
10215 let reply = svc
10216 .rebase_with(
10217 RebaseRequest {
10218 confirmed: true,
10219 onto: Some("main".into()),
10220 ..rebase_req(vec![wt.clone()])
10221 },
10222 crate::git::resolve_git_binary(),
10223 )
10224 .await
10225 .unwrap();
10226
10227 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10228 assert_eq!(
10229 worktrees[0].get("status").and_then(Value::as_str),
10230 Some("rebased"),
10231 "{reply}"
10232 );
10233 assert!(
10235 svc.registry.rebasing_paths().is_empty(),
10236 "the rebasing mark must be cleared after the execute"
10237 );
10238 }
10239
10240 #[tokio::test]
10241 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10242 let (_main, _parent, wt) = behind_worktree();
10246 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10247
10248 let svc = WorktreesService::new();
10249 let reply = svc
10250 .rebase_with(
10251 RebaseRequest {
10252 confirmed: true,
10253 onto: Some("main".into()),
10254 ..rebase_req(vec![wt.clone()])
10255 },
10256 crate::git::resolve_git_binary(),
10257 )
10258 .await
10259 .unwrap();
10260
10261 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10262 assert_eq!(
10263 worktrees[0].get("status").and_then(Value::as_str),
10264 Some("skipped"),
10265 "{reply}"
10266 );
10267 assert_eq!(
10268 worktrees[0].get("reason").and_then(Value::as_str),
10269 Some("dirty"),
10270 "{reply}"
10271 );
10272 }
10273
10274 #[tokio::test]
10275 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10276 let (_main, _parent, wt) = behind_worktree();
10282 std::fs::create_dir_all(wt.join(".git")).ok();
10285 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10286 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10287 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10288 assert_ne!(
10289 Repository::open(&wt).unwrap().state(),
10290 RepositoryState::Clean,
10291 "precondition: the worktree looks mid-rebase to git2"
10292 );
10293
10294 let svc = WorktreesService::new();
10295 let reply = svc
10296 .rebase_with(
10297 RebaseRequest {
10298 confirmed: true,
10299 onto: Some("main".into()),
10300 ..rebase_req(vec![wt.clone()])
10301 },
10302 crate::git::resolve_git_binary(),
10303 )
10304 .await
10305 .unwrap();
10306
10307 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10308 assert_eq!(
10309 worktrees[0].get("reason").and_then(Value::as_str),
10310 Some("operation-in-progress"),
10311 "{reply}"
10312 );
10313 assert_ne!(
10315 Repository::open(&wt).unwrap().state(),
10316 RepositoryState::Clean
10317 );
10318 }
10319
10320 #[test]
10321 fn rebase_request_maps_onto_engine_options() {
10322 let req = RebaseRequest {
10323 keep_conflicts: true,
10324 autostash: true,
10325 onto: Some("origin/release".into()),
10326 ..rebase_req(vec![PathBuf::from("/wt")])
10327 };
10328 let opts = req.options(PathBuf::from("/custom/git"));
10329 assert!(opts.keep_conflicts && opts.autostash);
10330 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10331 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10332 assert!(!opts.dry_run);
10336 }
10337
10338 #[test]
10339 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10340 let req = RebaseRequest {
10341 requester_key: Some("win-3".into()),
10342 check: true,
10343 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10344 };
10345 let plan = worktree_rebase::Plan {
10346 fetches: vec![worktree_rebase::FetchOutcome {
10347 repo_root: PathBuf::from("/repo"),
10348 onto: "origin/main".into(),
10349 fetched: true,
10350 ok: false,
10351 detail: Some("host unreachable".into()),
10352 }],
10353 worktrees: vec![
10354 worktree_rebase::WorktreeOutcome {
10355 path: PathBuf::from("/a"),
10356 branch: Some("a".into()),
10357 onto: "origin/main".into(),
10358 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10359 },
10360 worktree_rebase::WorktreeOutcome {
10361 path: PathBuf::from("/b"),
10362 branch: Some("b".into()),
10363 onto: "origin/main".into(),
10364 result: worktree_rebase::RebaseResult::UpToDate,
10365 },
10366 ],
10367 };
10368 let logs = capture_info(|| log_rebase_check(&req, &plan));
10369 assert!(logs.contains("rebase check"), "{logs}");
10370 assert!(logs.contains("win-3"), "{logs}");
10371 assert!(logs.contains("requested=2"), "{logs}");
10372 assert!(logs.contains("pending=1"), "{logs}");
10373 assert!(logs.contains("failed_fetches=1"), "{logs}");
10374 }
10375
10376 #[test]
10377 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10378 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10380 let outcome = |result| worktree_rebase::WorktreeOutcome {
10381 path: PathBuf::from("/x"),
10382 branch: Some("x".into()),
10383 onto: "origin/main".into(),
10384 result,
10385 };
10386 let outcomes = vec![
10387 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10388 outcome(worktree_rebase::RebaseResult::Conflict {
10389 detail: "CONFLICT".into(),
10390 left_in_place: true,
10391 }),
10392 outcome(worktree_rebase::RebaseResult::Skipped {
10393 reason: worktree_rebase::SkipReason::Dirty,
10394 }),
10395 ];
10396 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10397 assert!(logs.contains("rebase execute"), "{logs}");
10398 assert!(logs.contains("rebased=1"), "{logs}");
10399 assert!(logs.contains("conflicts=1"), "{logs}");
10400 assert!(logs.contains("left_in_place=1"), "{logs}");
10401 assert!(logs.contains("skipped=1"), "{logs}");
10402 assert!(logs.contains(r#"requester="-""#), "{logs}");
10403 }
10404
10405 fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10409 PushRequest {
10410 paths,
10411 requester_key: None,
10412 check: false,
10413 confirmed: false,
10414 }
10415 }
10416
10417 fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10424 let _guard = crate::git::worktree_batch::test_serial_lock();
10427 let root = tempfile::tempdir().unwrap();
10428 let origin = root.path().join("origin.git");
10429 let local = root.path().join("local");
10430 let wt = root.path().join("feature-wt");
10431 std::fs::create_dir_all(&origin).unwrap();
10432 std::fs::create_dir_all(&local).unwrap();
10433
10434 let git = |dir: &Path, args: &[&str]| {
10435 let out = crate::git::worktree_batch::run_git_in(
10436 &crate::git::resolve_git_binary(),
10437 dir,
10438 args,
10439 )
10440 .unwrap();
10441 assert!(
10442 out.status.success(),
10443 "git {args:?} failed: {}",
10444 String::from_utf8_lossy(&out.stderr)
10445 );
10446 };
10447
10448 git(&origin, &["init", "--bare", "-b", "main"]);
10449 git(&local, &["init", "-b", "main"]);
10450 git(&local, &["config", "user.name", "Test"]);
10451 git(&local, &["config", "user.email", "test@example.com"]);
10452 git(&local, &["config", "commit.gpgsign", "false"]);
10453 std::fs::write(local.join("f.txt"), "base\n").unwrap();
10454 git(&local, &["add", "f.txt"]);
10455 git(&local, &["commit", "-m", "base"]);
10456 git(
10457 &local,
10458 &["remote", "add", "origin", origin.to_str().unwrap()],
10459 );
10460 git(&local, &["push", "-u", "origin", "main"]);
10461 git(
10462 &local,
10463 &[
10464 "worktree",
10465 "add",
10466 "-b",
10467 "feature",
10468 wt.to_str().unwrap(),
10469 "main",
10470 ],
10471 );
10472 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10473 git(&wt, &["add", "g.txt"]);
10474 git(&wt, &["commit", "-m", "work"]);
10475 git(&wt, &["push", "-u", "origin", "feature"]);
10476 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10478
10479 (root, origin, std::fs::canonicalize(&wt).unwrap())
10480 }
10481
10482 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10484 Repository::open_bare(origin)
10485 .unwrap()
10486 .refname_to_id(refname)
10487 .ok()
10488 }
10489
10490 #[tokio::test]
10491 async fn push_with_refuses_an_empty_selection() {
10492 let svc = WorktreesService::new();
10494 let err = svc
10495 .push_with(push_req(Vec::new()), PathBuf::from("git"))
10496 .await
10497 .unwrap_err()
10498 .to_string();
10499 assert!(err.contains("at least one path"), "{err}");
10500 }
10501
10502 #[tokio::test]
10503 async fn push_with_phase_one_reports_without_publishing() {
10504 let (_root, origin, wt) = rewritten_worktree();
10505 let before = origin_tip(&origin, "refs/heads/feature");
10506
10507 let svc = WorktreesService::new();
10508 let reply = svc
10509 .push_with(
10510 PushRequest {
10511 check: true,
10512 ..push_req(vec![wt.clone()])
10513 },
10514 crate::git::resolve_git_binary(),
10515 )
10516 .await
10517 .unwrap();
10518
10519 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10520 assert_eq!(worktrees.len(), 1, "{reply}");
10521 assert_eq!(
10522 worktrees[0].get("status").and_then(Value::as_str),
10523 Some("would-force"),
10524 "{reply}"
10525 );
10526 assert!(
10527 reply.get("fetches").is_none(),
10528 "a push plan contacts no remote, so it reports no fetches: {reply}"
10529 );
10530 assert_eq!(
10531 origin_tip(&origin, "refs/heads/feature"),
10532 before,
10533 "phase 1 must publish nothing"
10534 );
10535 assert!(
10536 svc.registry.pushing_paths().is_empty(),
10537 "phase 1 must not mark a row as in flight"
10538 );
10539 }
10540
10541 #[tokio::test]
10542 async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10543 let (_root, origin, wt) = rewritten_worktree();
10544 let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10545
10546 let svc = WorktreesService::new();
10547 let reply = svc
10548 .push_with(
10549 PushRequest {
10550 confirmed: true,
10551 ..push_req(vec![wt.clone()])
10552 },
10553 crate::git::resolve_git_binary(),
10554 )
10555 .await
10556 .unwrap();
10557
10558 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10559 assert_eq!(
10560 worktrees[0].get("status").and_then(Value::as_str),
10561 Some("pushed"),
10562 "{reply}"
10563 );
10564 assert_eq!(
10565 worktrees[0].get("forced").and_then(Value::as_bool),
10566 Some(true),
10567 "a rewritten branch is published under the lease: {reply}"
10568 );
10569 assert_eq!(
10570 origin_tip(&origin, "refs/heads/feature"),
10571 rewritten,
10572 "the remote must carry the rewritten tip"
10573 );
10574 assert!(
10575 svc.registry.pushing_paths().is_empty(),
10576 "the pushing mark must be cleared after the execute — a push writes no \
10577 on-disk state, so nothing else could ever correct a leftover"
10578 );
10579 }
10580
10581 #[tokio::test]
10582 async fn push_resolves_the_git_binary_for_itself() {
10583 let (_root, origin, wt) = rewritten_worktree();
10587 let before = origin_tip(&origin, "refs/heads/feature");
10588
10589 let svc = WorktreesService::new();
10590 let reply = svc
10591 .push(PushRequest {
10592 check: true,
10593 ..push_req(vec![wt])
10594 })
10595 .await
10596 .unwrap();
10597
10598 assert_eq!(
10599 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10600 .get("status")
10601 .and_then(Value::as_str),
10602 Some("would-force"),
10603 "{reply}"
10604 );
10605 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10606 }
10607
10608 #[tokio::test]
10609 async fn push_with_defaults_to_report_only_without_confirmation() {
10610 let (_root, origin, wt) = rewritten_worktree();
10613 let before = origin_tip(&origin, "refs/heads/feature");
10614
10615 let svc = WorktreesService::new();
10616 let reply = svc
10617 .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10618 .await
10619 .unwrap();
10620
10621 assert_eq!(
10622 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10623 .get("status")
10624 .and_then(Value::as_str),
10625 Some("would-force"),
10626 "{reply}"
10627 );
10628 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10629 }
10630
10631 #[tokio::test]
10632 async fn push_with_refuses_to_force_the_remote_default_branch() {
10633 let (root, origin, _wt) = rewritten_worktree();
10636 let local = root.path().join("local");
10637 let before = origin_tip(&origin, "refs/heads/main");
10638 crate::git::worktree_batch::run_git_in(
10639 &crate::git::resolve_git_binary(),
10640 &local,
10641 &["commit", "--amend", "-m", "rewritten main"],
10642 )
10643 .unwrap();
10644
10645 let svc = WorktreesService::new();
10646 let reply = svc
10647 .push_with(
10648 PushRequest {
10649 confirmed: true,
10650 ..push_req(vec![local.clone()])
10651 },
10652 crate::git::resolve_git_binary(),
10653 )
10654 .await
10655 .unwrap();
10656
10657 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10658 assert_eq!(
10659 worktrees[0].get("reason").and_then(Value::as_str),
10660 Some("default-branch-force-push"),
10661 "{reply}"
10662 );
10663 assert_eq!(
10664 origin_tip(&origin, "refs/heads/main"),
10665 before,
10666 "the default branch's published history must be untouched"
10667 );
10668 }
10669
10670 #[test]
10671 fn log_push_check_separates_the_force_count_from_the_pending_count() {
10672 let req = PushRequest {
10673 requester_key: Some("win-7".into()),
10674 check: true,
10675 ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10676 };
10677 let outcome = |result| worktree_push::WorktreeOutcome {
10678 path: PathBuf::from("/x"),
10679 branch: Some("x".into()),
10680 remote: "origin".into(),
10681 remote_branch: "x".into(),
10682 result,
10683 };
10684 let plan = worktree_push::Plan {
10685 worktrees: vec![
10686 outcome(worktree_push::PushResult::WouldForce {
10687 ahead: 1,
10688 behind: 1,
10689 }),
10690 outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10691 outcome(worktree_push::PushResult::Skipped {
10692 reason: worktree_push::SkipReason::DefaultBranchForcePush,
10693 }),
10694 ],
10695 };
10696 let logs = capture_info(|| log_push_check(&req, &plan));
10697 assert!(logs.contains("push check"), "{logs}");
10698 assert!(logs.contains("pending=2"), "{logs}");
10699 assert!(logs.contains("forced=1"), "{logs}");
10700 assert!(logs.contains("skipped=1"), "{logs}");
10701 assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10702 }
10703
10704 #[test]
10705 fn log_push_execute_counts_lease_refusals_separately() {
10706 let req = push_req(vec![PathBuf::from("/a")]);
10707 let outcome = |result| worktree_push::WorktreeOutcome {
10708 path: PathBuf::from("/x"),
10709 branch: Some("x".into()),
10710 remote: "origin".into(),
10711 remote_branch: "x".into(),
10712 result,
10713 };
10714 let outcomes = vec![
10715 outcome(worktree_push::PushResult::Pushed { forced: true }),
10716 outcome(worktree_push::PushResult::Pushed { forced: false }),
10717 outcome(worktree_push::PushResult::Created),
10718 outcome(worktree_push::PushResult::Rejected {
10719 detail: "stale info".into(),
10720 stale: true,
10721 }),
10722 outcome(worktree_push::PushResult::Rejected {
10723 detail: "pre-receive hook declined".into(),
10724 stale: false,
10725 }),
10726 ];
10727 let logs = capture_info(|| log_push_execute(&req, &outcomes));
10728 assert!(logs.contains("push execute"), "{logs}");
10729 assert!(logs.contains("pushed=2"), "{logs}");
10730 assert!(logs.contains("forced=1"), "{logs}");
10731 assert!(logs.contains("created=1"), "{logs}");
10732 assert!(logs.contains("rejected=2"), "{logs}");
10733 assert!(
10734 logs.contains("stale_rejected=1"),
10735 "a lease refusal is the interesting half of a rejection: {logs}"
10736 );
10737 }
10738
10739 #[test]
10740 fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10741 let dir = tempfile::tempdir().unwrap();
10742 let path = canonical(dir.path());
10743
10744 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10745 assert!(!quiet.pushing);
10746 let json = serde_json::to_value(&quiet).unwrap();
10747 assert!(
10748 json.get("pushing").is_none(),
10749 "an idle row stays byte-identical for an older client: {json}"
10750 );
10751
10752 let busy = worktree_entry(
10753 &path,
10754 true,
10755 &HashMap::new(),
10756 &InFlight {
10757 pushing: [path.clone()].into(),
10758 rebasing: HashSet::new(),
10759 },
10760 );
10761 assert!(busy.pushing, "the registry's transient mark rides through");
10762 assert!(
10763 !busy.rebasing,
10764 "the two cues are independent — a push must not read as a rebase"
10765 );
10766 assert_eq!(
10767 serde_json::to_value(&busy).unwrap()["pushing"],
10768 serde_json::json!(true)
10769 );
10770 }
10771
10772 #[test]
10773 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10774 assert_eq!(operation_slug(RepositoryState::Clean), None);
10775 assert_eq!(
10776 operation_slug(RepositoryState::Rebase).as_deref(),
10777 Some("rebase")
10778 );
10779 assert_eq!(
10780 operation_slug(RepositoryState::RebaseMerge).as_deref(),
10781 Some("rebase"),
10782 "the merge-backend rebase is still just a rebase to the user"
10783 );
10784 assert_eq!(
10785 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10786 Some("rebase-interactive")
10787 );
10788 assert_eq!(
10789 operation_slug(RepositoryState::Merge).as_deref(),
10790 Some("merge")
10791 );
10792 assert_eq!(
10793 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10794 Some("cherry-pick")
10795 );
10796 assert_eq!(
10797 operation_slug(RepositoryState::RevertSequence).as_deref(),
10798 Some("revert")
10799 );
10800 assert_eq!(
10801 operation_slug(RepositoryState::Bisect).as_deref(),
10802 Some("bisect")
10803 );
10804 assert_eq!(
10805 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10806 Some("apply-mailbox")
10807 );
10808 }
10809
10810 #[test]
10811 fn git_status_omits_operation_for_a_clean_worktree() {
10812 let dir = tempfile::tempdir().unwrap();
10813 let _repo = diverging_repo(dir.path());
10814 assert_eq!(
10815 git_status(dir.path()).operation,
10816 None,
10817 "a clean worktree carries no operation, so the field stays off the wire"
10818 );
10819 }
10820
10821 #[test]
10822 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10823 let main_dir = tempfile::tempdir().unwrap();
10824 let repo = init_repo(main_dir.path());
10825 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10826 repo.set_head("refs/heads/main").unwrap();
10827 let path = canonical(main_dir.path());
10828
10829 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10830 assert!(!quiet.rebasing);
10831 let json = serde_json::to_value(&quiet).unwrap();
10833 assert!(json.get("rebasing").is_none(), "{json}");
10834 assert!(json.get("operation").is_none(), "{json}");
10835
10836 let busy = worktree_entry(
10837 &path,
10838 true,
10839 &HashMap::new(),
10840 &InFlight {
10841 rebasing: std::iter::once(path.clone()).collect(),
10842 pushing: HashSet::new(),
10843 },
10844 );
10845 assert!(busy.rebasing, "the registry's transient mark rides through");
10846 assert_eq!(
10847 serde_json::to_value(&busy).unwrap()["rebasing"],
10848 serde_json::Value::Bool(true)
10849 );
10850 }
10851
10852 #[test]
10853 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10854 assert_eq!(note_kinds(&[]), "-");
10855 assert_eq!(
10856 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10857 "dirty,untracked"
10858 );
10859 }
10860
10861 #[test]
10862 fn is_self_close_true_only_when_requester_owns_an_open_window() {
10863 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10864 assert!(is_self_close(Some("w1"), &windows));
10865 assert!(
10866 !is_self_close(Some("w3"), &windows),
10867 "requester owns no window"
10868 );
10869 assert!(!is_self_close(None, &windows), "no requester");
10870 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10871 }
10872
10873 #[test]
10874 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10875 let logs = capture_info(|| {
10876 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10877 assert_eq!(reply, json!({ "removed": true }));
10878 });
10879 assert!(
10880 logs.contains("worktrees close: linked worktree pruned"),
10881 "a successful prune must log an INFO audit line, got: {logs}"
10882 );
10883 assert!(
10884 logs.contains("/wt/feature"),
10885 "the target path must ride the line, got: {logs}"
10886 );
10887 }
10888
10889 #[test]
10890 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10891 let logs = capture_info(|| {
10896 let reply =
10897 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10898 assert_eq!(reply, json!({ "removed": true }));
10899 });
10900 assert!(
10901 logs.contains("worktrees close: nothing to prune, worktree already removed"),
10902 "an already-gone close must log its own outcome, got: {logs}"
10903 );
10904 assert!(
10905 !logs.contains("linked worktree pruned"),
10906 "an already-gone close must not claim it pruned, got: {logs}"
10907 );
10908 }
10909
10910 #[test]
10911 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10912 let logs = capture_info(|| {
10914 let err = log_close_error(
10915 Path::new("/wt/feature"),
10916 "safety check",
10917 anyhow!("not a git worktree"),
10918 );
10919 assert_eq!(
10920 err.to_string(),
10921 "not a git worktree",
10922 "err propagates unchanged"
10923 );
10924 });
10925 assert!(
10926 logs.contains("worktrees close: safety check failed"),
10927 "a failed phase must log an ERROR audit line, got: {logs}"
10928 );
10929 assert!(
10930 logs.contains("not a git worktree"),
10931 "the cause must ride the line, got: {logs}"
10932 );
10933 assert!(
10934 logs.contains("/wt/feature"),
10935 "the target path must ride the line, got: {logs}"
10936 );
10937 }
10938
10939 #[test]
10940 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10941 let logs = capture_info(|| {
10942 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10943 assert!(err.is_err(), "a prune failure must propagate");
10944 });
10945 assert!(
10946 logs.contains("worktrees close: worktree prune failed"),
10947 "a prune failure must log a WARN audit line, got: {logs}"
10948 );
10949 assert!(
10950 logs.contains("locked"),
10951 "the failure cause must ride the line, got: {logs}"
10952 );
10953 }
10954
10955 #[test]
10956 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10957 let git = GitSafety {
10958 is_main: false,
10959 removable: true,
10960 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10961 info: vec![],
10962 };
10963 let logs = capture_info(|| {
10964 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10965 });
10966 assert!(
10967 logs.contains("worktrees close: safety check"),
10968 "phase-1 must log a safety-check line, got: {logs}"
10969 );
10970 assert!(
10971 logs.contains("/wt/feature"),
10972 "the path must ride the line, got: {logs}"
10973 );
10974 assert!(
10975 logs.contains("window_key=\"win-42\""),
10976 "the owning window key must ride the line, got: {logs}"
10977 );
10978 assert!(logs.contains("removable=true"), "got: {logs}");
10979 assert!(logs.contains("is_main=false"), "got: {logs}");
10980 assert!(logs.contains("open=true"), "got: {logs}");
10981 assert!(
10982 logs.contains("risks=dirty,untracked"),
10983 "the blocking risk kinds must ride the line, got: {logs}"
10984 );
10985 }
10986
10987 #[test]
10988 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10989 let git = GitSafety {
10990 is_main: false,
10991 removable: true,
10992 risks: vec![],
10993 info: vec![],
10994 };
10995 let logs = capture_info(|| {
10996 log_safety_check(Path::new("/wt/feature"), None, &git, false);
10997 });
10998 assert!(
10999 logs.contains("window_key=\"-\""),
11000 "no owning window → dash, got: {logs}"
11001 );
11002 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11003 }
11004
11005 #[test]
11006 fn log_executing_logs_the_routing_decision() {
11007 let logs = capture_info(|| {
11008 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11009 });
11010 assert!(
11011 logs.contains("worktrees close: executing"),
11012 "phase-2 must log the execute routing, got: {logs}"
11013 );
11014 assert!(
11015 logs.contains("requester=\"win-7\""),
11016 "the requester key must ride the line, got: {logs}"
11017 );
11018 assert!(logs.contains("remove=true"), "got: {logs}");
11019 assert!(logs.contains("self_close=false"), "got: {logs}");
11020 assert!(logs.contains("cross_window=3"), "got: {logs}");
11021 }
11022
11023 #[test]
11024 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11025 let logs = capture_info(|| {
11026 log_close_abort(
11027 Path::new("/wt/feature"),
11028 &anyhow!("window(s) did not close in time: win-9"),
11029 );
11030 });
11031 assert!(
11032 logs.contains("worktrees close: aborted"),
11033 "an abort must log a WARN audit line, got: {logs}"
11034 );
11035 assert!(
11036 logs.contains("/wt/feature"),
11037 "the path must ride the line, got: {logs}"
11038 );
11039 assert!(
11040 logs.contains("win-9"),
11041 "the still-open window must ride the line, got: {logs}"
11042 );
11043 }
11044
11045 #[test]
11046 fn log_window_closed_logs_the_no_removal_outcome() {
11047 let logs = capture_info(|| {
11048 log_window_closed(Path::new("/wt/feature"));
11049 });
11050 assert!(
11051 logs.contains("worktrees close: window closed, no removal"),
11052 "a remove:false close must log the no-removal outcome, got: {logs}"
11053 );
11054 assert!(
11055 logs.contains("/wt/feature"),
11056 "the path must ride the line, got: {logs}"
11057 );
11058 }
11059
11060 #[test]
11061 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11062 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11066 let admin = main.path().join(".git").join("worktrees").join("feature");
11067 assert!(admin.exists(), "admin metadata should exist before removal");
11068
11069 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11070
11071 assert!(!wt_path.exists(), "the working directory should be gone");
11072 assert!(!admin.exists(), "the admin metadata should be pruned");
11073 let main_repo = Repository::open(main.path()).unwrap();
11074 assert_eq!(
11075 main_repo.worktrees().unwrap().len(),
11076 0,
11077 "git should no longer track the worktree"
11078 );
11079 }
11080
11081 #[test]
11082 fn remove_worktree_recovers_a_half_removed_orphan() {
11083 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11088 let admin = main.path().join(".git").join("worktrees").join("feature");
11089 std::fs::remove_dir_all(&admin).unwrap();
11091 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11092 assert!(
11093 Repository::open(&wt_path).is_err(),
11094 "the orphan should not open as a repo"
11095 );
11096
11097 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11098 assert!(
11099 !wt_path.exists(),
11100 "the leftover directory should be removed"
11101 );
11102 }
11103
11104 fn orphaned_admin_worktree() -> (
11112 tempfile::TempDir,
11113 PathBuf,
11114 tempfile::TempDir,
11115 PathBuf,
11116 PathBuf,
11117 ) {
11118 let main_dir = tempfile::tempdir().unwrap();
11119 let main_root = main_dir.path().canonicalize().unwrap();
11120 let repo = init_repo(&main_root);
11121 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11122 repo.set_head("refs/heads/trunk").unwrap();
11123 let wt_parent = tempfile::tempdir().unwrap();
11124 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11125 add_worktree(&repo, a, &wt_path, "feature");
11126 let admin = main_root.join(".git").join("worktrees").join("feature");
11127 assert!(admin.exists(), "admin metadata exists before the orphaning");
11128 std::fs::remove_dir_all(&wt_path).unwrap();
11130 (main_dir, main_root, wt_parent, wt_path, admin)
11131 }
11132
11133 fn window_on(folder: &Path) -> WindowEntry {
11134 WindowEntry {
11135 key: "w".to_string(),
11136 folders: vec![folder.to_path_buf()],
11137 repo: None,
11138 title: None,
11139 pid: None,
11140 last_seen: Utc::now(),
11141 }
11142 }
11143
11144 #[test]
11145 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11146 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11150
11151 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11152
11153 assert_eq!(
11154 removed,
11155 Removal::Pruned,
11156 "the orphaned admin must be pruned"
11157 );
11158 assert!(!admin.exists(), "the admin metadata should be gone");
11159 let main_repo = Repository::open(&main_root).unwrap();
11160 assert!(
11161 main_repo.worktrees().unwrap().is_empty(),
11162 "git should no longer track the orphaned worktree"
11163 );
11164 }
11165
11166 #[test]
11167 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11168 let main_dir = tempfile::tempdir().unwrap();
11172 let main_root = main_dir.path().canonicalize().unwrap();
11173 let repo = init_repo(&main_root);
11174 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11175 repo.set_head("refs/heads/trunk").unwrap();
11176 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11178 let wt_path = main_root.join(".nested").join("feature-wt");
11179 add_worktree(&repo, a, &wt_path, "feature");
11180 let admin = main_root.join(".git").join("worktrees").join("feature");
11181 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11182
11183 let removed = remove_worktree(&wt_path, &[]).unwrap();
11184
11185 assert_eq!(removed, Removal::Pruned);
11186 assert!(!admin.exists(), "the admin metadata should be gone");
11187 }
11188
11189 #[test]
11190 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11191 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11195 std::fs::remove_dir_all(&admin).unwrap();
11197
11198 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11199
11200 assert_eq!(removed, Removal::AlreadyGone);
11201 }
11202
11203 #[test]
11204 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11205 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11206 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11209 assert!(
11210 roots.contains(&main_root),
11211 "the owning main repo must be a candidate, got: {roots:?}"
11212 );
11213 }
11214
11215 #[test]
11216 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11217 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11221 let junk = tempfile::tempdir().unwrap();
11222
11223 let removed =
11224 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11225
11226 assert_eq!(removed, Removal::Pruned);
11227 assert!(
11228 !admin.exists(),
11229 "the real owner must still prune the orphan"
11230 );
11231 }
11232
11233 #[test]
11234 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11235 let main_dir = tempfile::tempdir().unwrap();
11239 let main_root = main_dir.path().canonicalize().unwrap();
11240 let repo = init_repo(&main_root);
11241 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11242 repo.set_head("refs/heads/trunk").unwrap();
11243 let wt_parent = tempfile::tempdir().unwrap();
11244 let wt_root = wt_parent.path().canonicalize().unwrap();
11245 let orphan = wt_root.join("orphan-wt");
11246 let sibling = wt_root.join("sibling-wt");
11247 add_worktree(&repo, a, &orphan, "orphan");
11248 add_worktree(&repo, a, &sibling, "sibling");
11249 let admin = main_root.join(".git").join("worktrees").join("orphan");
11250 std::fs::remove_dir_all(&orphan).unwrap();
11251
11252 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11255
11256 assert_eq!(removed, Removal::Pruned);
11257 assert!(
11258 !admin.exists(),
11259 "the orphan's admin metadata must be pruned"
11260 );
11261 }
11262
11263 #[test]
11264 fn prune_orphaned_admin_refuses_a_locked_orphan() {
11265 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11269 let main_repo = Repository::open(&main_root).unwrap();
11270 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11271 main_repo
11272 .find_worktree(&name)
11273 .unwrap()
11274 .lock(Some("in use"))
11275 .unwrap();
11276
11277 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11278
11279 assert!(
11280 err.to_string().contains("locked"),
11281 "a locked orphan must be refused, got: {err:#}"
11282 );
11283 assert!(admin.exists(), "a refused prune must leave the admin entry");
11284 }
11285
11286 #[test]
11287 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11288 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11289 assert!(!is_orphaned_worktree(&wt_path));
11291 assert!(!is_orphaned_worktree(main.path()));
11293 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11295 .unwrap();
11296 assert!(is_orphaned_worktree(&wt_path));
11297 }
11298
11299 #[test]
11300 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11301 let tmp = tempfile::tempdir().unwrap();
11302 let missing = tmp.path().join("gone");
11303 assert!(remove_dir_all_retrying(&missing).is_ok());
11304 }
11305
11306 #[test]
11307 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11308 use std::io::Error;
11309 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11310 assert!(
11311 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11312 "errno {errno} is the concurrent-writer race and must be retried"
11313 );
11314 }
11315 for errno in [
11318 nix::libc::EACCES,
11319 nix::libc::EPERM,
11320 nix::libc::EROFS,
11321 nix::libc::ENOTDIR,
11322 ] {
11323 assert!(
11324 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11325 "errno {errno} is permanent and must not be retried"
11326 );
11327 }
11328 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11330 }
11331
11332 #[test]
11333 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11334 let tmp = tempfile::tempdir().unwrap();
11338 let file = tmp.path().join("not-a-directory");
11339 std::fs::write(&file, b"x").unwrap();
11340
11341 let mut attempts = 0;
11342 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11343 attempts += 1;
11344 std::fs::remove_dir_all(&file)
11345 })
11346 .unwrap_err();
11347
11348 assert_eq!(attempts, 1, "a permanent error must not be retried");
11349 assert!(
11350 err.to_string()
11351 .contains("failed to remove worktree directory"),
11352 "unexpected error: {err:#}"
11353 );
11354 assert!(err.source().is_some(), "the io::Error cause is preserved");
11355 assert!(file.exists());
11356 }
11357
11358 #[test]
11359 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11360 let tmp = tempfile::tempdir().unwrap();
11364 let mut attempts = 0;
11365 let backoff = [Duration::ZERO, Duration::ZERO];
11366 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11367 attempts += 1;
11368 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11369 })
11370 .unwrap_err();
11371
11372 assert_eq!(attempts, backoff.len() + 1);
11374 assert!(
11375 err.to_string()
11376 .contains("failed to remove worktree directory"),
11377 "unexpected error: {err:#}"
11378 );
11379 }
11380
11381 #[test]
11382 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11383 let tmp = tempfile::tempdir().unwrap();
11386 let mut attempts = 0;
11387 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11388 attempts += 1;
11389 if attempts < 3 {
11390 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11391 } else {
11392 Ok(())
11393 }
11394 });
11395 assert!(result.is_ok(), "{result:?}");
11396 assert_eq!(attempts, 3);
11397 }
11398
11399 #[test]
11400 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11401 let tmp = tempfile::tempdir().unwrap();
11404 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11405 assert!(!is_orphaned_worktree(tmp.path()));
11406 }
11407
11408 #[test]
11409 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11410 let tmp = tempfile::tempdir().unwrap();
11413 let plain = tmp.path().join("plain");
11414 std::fs::create_dir(&plain).unwrap();
11415
11416 let err = remove_worktree(&plain, &[]).unwrap_err();
11417
11418 assert!(
11419 err.to_string().contains("not a git worktree"),
11420 "unexpected error: {err:#}"
11421 );
11422 assert!(plain.exists(), "a non-worktree path must be left alone");
11423 }
11424
11425 #[test]
11426 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11427 use std::sync::atomic::{AtomicBool, Ordering};
11433 use std::sync::Arc;
11434
11435 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11436 let nested = wt_path.join("target").join("nested");
11442 std::fs::create_dir_all(&nested).unwrap();
11443
11444 let stop = Arc::new(AtomicBool::new(false));
11445 let writer_stop = Arc::clone(&stop);
11446 let writer_dir = nested;
11447 let writer = std::thread::spawn(move || {
11448 let mut n = 0u64;
11449 let deadline = std::time::Instant::now() + Duration::from_millis(400);
11452 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11453 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11458 n += 1;
11459 }
11460 });
11461
11462 let result = remove_worktree(&wt_path, &[]);
11463 stop.store(true, Ordering::Relaxed);
11464 writer.join().unwrap();
11465
11466 assert!(
11467 result.is_ok(),
11468 "removal should retry past the writer: {result:?}"
11469 );
11470 assert!(!wt_path.exists(), "the worktree directory should be gone");
11471 }
11472
11473 #[tokio::test]
11474 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11475 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11476 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11478
11479 let svc = WorktreesService::new();
11480 let report = svc
11481 .handle("close", json!({ "path": wt_path, "remove": true }))
11482 .await
11483 .unwrap();
11484 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11485 assert!(
11486 risks
11487 .iter()
11488 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11489 "expected an untracked risk: {report}"
11490 );
11491 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11493 assert!(wt_path.exists());
11495 }
11496
11497 #[tokio::test]
11498 async fn close_confirmed_removes_a_dirty_worktree() {
11499 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11500 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11501 let svc = WorktreesService::new();
11502 let reply = svc
11504 .handle(
11505 "close",
11506 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11507 )
11508 .await
11509 .unwrap();
11510 assert_eq!(reply, json!({ "removed": true }));
11511 assert!(!wt_path.exists());
11512 }
11513
11514 #[tokio::test]
11515 async fn close_refuses_to_remove_the_main_working_tree() {
11516 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11517 let svc = WorktreesService::new();
11518 let report = svc
11520 .handle("close", json!({ "path": main.path(), "remove": true }))
11521 .await
11522 .unwrap();
11523 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11524 assert_eq!(
11525 report.get("removable").and_then(Value::as_bool),
11526 Some(false)
11527 );
11528 assert!(svc
11531 .handle(
11532 "close",
11533 json!({ "path": main.path(), "remove": true, "confirmed": true }),
11534 )
11535 .await
11536 .is_err());
11537 assert!(main.path().exists());
11538 }
11539
11540 #[tokio::test]
11541 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11542 let main_dir = tempfile::tempdir().unwrap();
11546 let repo = init_repo(main_dir.path());
11547 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11548 repo.set_head("refs/heads/trunk").unwrap();
11549 let wt_parent = tempfile::tempdir().unwrap();
11550 let wt_path = wt_parent.path().join("main-wt");
11551 add_worktree(&repo, a, &wt_path, "main");
11552
11553 let svc = WorktreesService::new();
11554 let reply = svc
11555 .handle(
11556 "close",
11557 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11558 )
11559 .await
11560 .unwrap();
11561 assert_eq!(reply, json!({ "removed": true }));
11562 assert!(!wt_path.exists());
11563 assert!(
11565 repo.find_branch("main", git2::BranchType::Local).is_ok(),
11566 "the default branch must survive worktree removal"
11567 );
11568 }
11569
11570 #[tokio::test]
11571 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11572 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11573 let svc = WorktreesService::new();
11574 svc.handle(
11576 "close",
11577 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11578 )
11579 .await
11580 .unwrap();
11581 let reply = svc
11584 .handle(
11585 "close",
11586 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11587 )
11588 .await
11589 .unwrap();
11590 assert_eq!(reply, json!({ "removed": true }));
11591 }
11592
11593 #[tokio::test]
11594 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11595 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11600 let svc = WorktreesService::new();
11601 svc.handle(
11604 "register",
11605 register_payload("main-w", None, &main_root.display().to_string()),
11606 )
11607 .await
11608 .unwrap();
11609
11610 let before = svc.handle("tree", Value::Null).await.unwrap();
11612 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11613 assert_eq!(
11614 worktrees_before, 2,
11615 "the orphaned row is present before close"
11616 );
11617
11618 let reply = svc
11619 .handle(
11620 "close",
11621 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11622 )
11623 .await
11624 .unwrap();
11625 assert_eq!(reply, json!({ "removed": true }));
11626
11627 assert!(!admin.exists(), "the admin metadata must be pruned");
11629 let after = svc.handle("tree", Value::Null).await.unwrap();
11630 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11631 assert_eq!(worktrees_after, 1, "only the main working tree remains");
11632 }
11633
11634 #[tokio::test]
11635 async fn close_safety_check_detects_detached_head_unreachable_commits() {
11636 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11637 let wt_repo = Repository::open(&wt_path).unwrap();
11640 let parent_oid = wt_repo.head().unwrap().target().unwrap();
11641 let parent = wt_repo.find_commit(parent_oid).unwrap();
11642 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11643 wt_repo.set_head_detached(orphan).unwrap();
11644
11645 let svc = WorktreesService::new();
11646 let report = svc
11647 .handle("close", json!({ "path": wt_path, "remove": true }))
11648 .await
11649 .unwrap();
11650 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11651 assert!(
11652 risks
11653 .iter()
11654 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11655 "expected an unreachable-commits risk: {report}"
11656 );
11657 }
11658
11659 #[tokio::test]
11660 async fn close_self_close_removes_when_the_requester_owns_the_target() {
11661 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11662 let svc = WorktreesService::new();
11663 svc.handle(
11667 "register",
11668 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11669 )
11670 .await
11671 .unwrap();
11672 let reply = svc
11673 .handle(
11674 "close",
11675 json!({
11676 "path": wt_path,
11677 "remove": true,
11678 "confirmed": true,
11679 "requester_key": "w1",
11680 }),
11681 )
11682 .await
11683 .unwrap();
11684 assert_eq!(reply, json!({ "removed": true }));
11685 assert!(!wt_path.exists());
11686 }
11687
11688 #[tokio::test]
11689 async fn close_safety_check_surfaces_the_owning_window() {
11690 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11691 let svc = WorktreesService::new();
11692 svc.handle(
11695 "register",
11696 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11697 )
11698 .await
11699 .unwrap();
11700 let report = svc
11701 .handle("close", json!({ "path": wt_path, "remove": true }))
11702 .await
11703 .unwrap();
11704 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11705 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11706 assert_eq!(
11707 report.get("window_folder_count").and_then(Value::as_u64),
11708 Some(2)
11709 );
11710 }
11711
11712 #[tokio::test]
11713 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11714 let svc = WorktreesService::new();
11715 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11716 .await
11717 .unwrap();
11718 assert_eq!(
11720 svc.handle("heartbeat", json!({ "key": "w1" }))
11721 .await
11722 .unwrap(),
11723 json!({ "known": true })
11724 );
11725 svc.registry.mark_close_pending("w1");
11727 assert_eq!(
11728 svc.handle("heartbeat", json!({ "key": "w1" }))
11729 .await
11730 .unwrap(),
11731 json!({ "known": true, "close": true })
11732 );
11733 assert_eq!(
11734 svc.handle("heartbeat", json!({ "key": "w1" }))
11735 .await
11736 .unwrap(),
11737 json!({ "known": true })
11738 );
11739 }
11740
11741 #[tokio::test]
11744 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11745 let svc = WorktreesService::new();
11746 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11747 .await
11748 .unwrap();
11749 assert_eq!(
11752 svc.handle("heartbeat", json!({ "key": "w1" }))
11753 .await
11754 .unwrap(),
11755 json!({ "known": true })
11756 );
11757 svc.registry.mark_reload_pending("w1");
11759 assert_eq!(
11760 svc.handle("heartbeat", json!({ "key": "w1" }))
11761 .await
11762 .unwrap(),
11763 json!({ "known": true, "reload": true })
11764 );
11765 assert_eq!(
11766 svc.handle("heartbeat", json!({ "key": "w1" }))
11767 .await
11768 .unwrap(),
11769 json!({ "known": true })
11770 );
11771 }
11772
11773 #[tokio::test]
11774 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11775 let svc = WorktreesService::new();
11776 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11777 .await
11778 .unwrap();
11779 svc.registry.mark_close_pending("w1");
11783 svc.registry.mark_reload_pending("w1");
11784 assert_eq!(
11785 svc.handle("heartbeat", json!({ "key": "w1" }))
11786 .await
11787 .unwrap(),
11788 json!({ "known": true, "close": true, "reload": true })
11789 );
11790 assert_eq!(
11791 svc.handle("heartbeat", json!({ "key": "w1" }))
11792 .await
11793 .unwrap(),
11794 json!({ "known": true })
11795 );
11796 }
11797
11798 #[tokio::test]
11799 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11800 let svc = WorktreesService::new();
11801 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11802 .await
11803 .unwrap();
11804 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11805 .await
11806 .unwrap();
11807
11808 let reply = svc
11811 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11812 .await
11813 .unwrap();
11814 assert_eq!(
11815 reply,
11816 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11817 );
11818
11819 assert!(svc.registry.take_reload_pending("w1"));
11822 assert!(svc.registry.take_reload_pending("w2"));
11823 assert!(!svc.registry.take_reload_pending("ghost"));
11824 }
11825
11826 #[tokio::test]
11827 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11828 let svc = WorktreesService::new();
11829 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11830 .await
11831 .unwrap();
11832
11833 assert_eq!(
11836 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11837 .await
11838 .unwrap(),
11839 json!({ "requested": 1, "signalled": 1, "unknown": [] })
11840 );
11841
11842 assert_eq!(
11845 svc.handle("reload", json!({ "target_keys": [] }))
11846 .await
11847 .unwrap(),
11848 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11849 );
11850 assert_eq!(
11851 svc.handle("reload", json!({})).await.unwrap(),
11852 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11853 );
11854 }
11855
11856 #[tokio::test]
11857 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11858 let svc = WorktreesService::new();
11859 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11860 .await
11861 .unwrap();
11862 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11863 .await
11864 .unwrap();
11865
11866 svc.handle("reload", json!({ "target_keys": ["w2"] }))
11869 .await
11870 .unwrap();
11871 assert_eq!(
11872 svc.handle("heartbeat", json!({ "key": "w2" }))
11873 .await
11874 .unwrap(),
11875 json!({ "known": true, "reload": true })
11876 );
11877 assert_eq!(
11879 svc.handle("heartbeat", json!({ "key": "w1" }))
11880 .await
11881 .unwrap(),
11882 json!({ "known": true })
11883 );
11884 }
11885
11886 #[tokio::test]
11887 async fn reload_op_treats_an_unregistered_window_as_unknown() {
11888 let svc = WorktreesService::new();
11889 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11890 .await
11891 .unwrap();
11892 svc.handle("unregister", json!({ "key": "w1" }))
11893 .await
11894 .unwrap();
11895 assert_eq!(
11898 svc.handle("reload", json!({ "target_keys": ["w1"] }))
11899 .await
11900 .unwrap(),
11901 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11902 );
11903 }
11904
11905 #[tokio::test]
11906 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11907 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11908 let svc = Arc::new(WorktreesService::new());
11909 svc.handle(
11911 "register",
11912 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11913 )
11914 .await
11915 .unwrap();
11916
11917 let svc2 = svc.clone();
11920 let path = wt_path.clone();
11921 let close = tokio::spawn(async move {
11922 svc2.handle(
11923 "close",
11924 json!({
11925 "path": path,
11926 "remove": true,
11927 "confirmed": true,
11928 "requester_key": "w1",
11929 }),
11930 )
11931 .await
11932 });
11933
11934 let mut saw_close = false;
11937 for _ in 0..200 {
11938 let hb = svc
11939 .handle("heartbeat", json!({ "key": "w2" }))
11940 .await
11941 .unwrap();
11942 if hb.get("close").and_then(Value::as_bool) == Some(true) {
11943 saw_close = true;
11944 svc.handle("unregister", json!({ "key": "w2" }))
11945 .await
11946 .unwrap();
11947 break;
11948 }
11949 tokio::time::sleep(Duration::from_millis(5)).await;
11950 }
11951 assert!(saw_close, "w2 should have been told to close");
11952
11953 let reply = close.await.unwrap().unwrap();
11955 assert_eq!(reply, json!({ "removed": true }));
11956 assert!(!wt_path.exists());
11957 }
11958
11959 #[tokio::test]
11960 async fn await_windows_closed_times_out_when_a_window_never_closes() {
11961 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11962 let svc = WorktreesService::new();
11963 svc.handle(
11964 "register",
11965 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11966 )
11967 .await
11968 .unwrap();
11969 let err = await_windows_closed(
11972 &svc.registry,
11973 &wt_path,
11974 Some("w1"),
11975 Duration::from_millis(150),
11976 Duration::from_millis(25),
11977 )
11978 .await
11979 .unwrap_err();
11980 assert!(
11981 err.to_string().contains("w2"),
11982 "error names the window: {err}"
11983 );
11984 await_windows_closed(
11986 &svc.registry,
11987 &wt_path,
11988 Some("w2"),
11989 Duration::from_millis(150),
11990 Duration::from_millis(25),
11991 )
11992 .await
11993 .unwrap();
11994 }
11995
11996 #[tokio::test]
11997 async fn close_window_without_remove_replies_closed_and_never_deletes() {
11998 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11999 let svc = WorktreesService::new();
12000 let reply = svc
12002 .handle("close", json!({ "path": main.path(), "remove": false }))
12003 .await
12004 .unwrap();
12005 assert_eq!(reply, json!({ "closed": true }));
12006 assert!(main.path().exists());
12007 }
12008
12009 #[tokio::test]
12010 async fn close_safety_check_flags_modified_tracked_files() {
12011 let main_dir = tempfile::tempdir().unwrap();
12015 let repo = init_repo(main_dir.path());
12016 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12017 repo.set_head("refs/heads/trunk").unwrap();
12018 let wt_parent = tempfile::tempdir().unwrap();
12019 let wt_path = wt_parent.path().join("feature-wt");
12020 add_worktree(&repo, a, &wt_path, "feature");
12021 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12022
12023 let svc = WorktreesService::new();
12024 let report = svc
12025 .handle("close", json!({ "path": wt_path, "remove": true }))
12026 .await
12027 .unwrap();
12028 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12029 assert!(
12030 risks
12031 .iter()
12032 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12033 "expected a dirty risk: {report}"
12034 );
12035 }
12036
12037 #[tokio::test]
12038 async fn close_safety_check_flags_an_in_progress_operation() {
12039 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12042 let wt_repo = Repository::open(&wt_path).unwrap();
12043 let head = wt_repo.head().unwrap().target().unwrap();
12044 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12045 assert_ne!(wt_repo.state(), RepositoryState::Clean);
12046
12047 let svc = WorktreesService::new();
12048 let report = svc
12049 .handle("close", json!({ "path": wt_path, "remove": true }))
12050 .await
12051 .unwrap();
12052 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12053 assert!(
12054 risks
12055 .iter()
12056 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12057 "expected an in-progress risk: {report}"
12058 );
12059 }
12060
12061 #[tokio::test]
12062 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12063 let main_dir = tempfile::tempdir().unwrap();
12067 let repo = init_repo(main_dir.path());
12068 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12069 repo.set_head("refs/heads/trunk").unwrap();
12070 let a_commit = repo.find_commit(a).unwrap();
12071 repo.branch("feature", &a_commit, false).unwrap();
12072 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12073 .unwrap();
12074 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12076 drop(a_commit);
12077 let mut cfg = repo.config().unwrap();
12078 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12079 .unwrap();
12080 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12081 .unwrap();
12082 cfg.set_str("branch.feature.remote", "origin").unwrap();
12083 cfg.set_str("branch.feature.merge", "refs/heads/feature")
12084 .unwrap();
12085 let wt_parent = tempfile::tempdir().unwrap();
12088 let wt_path = wt_parent.path().join("feature-wt");
12089 let reference = repo.find_reference("refs/heads/feature").unwrap();
12090 let mut opts = git2::WorktreeAddOptions::new();
12091 opts.reference(Some(&reference));
12092 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12093
12094 let svc = WorktreesService::new();
12095 let report = svc
12096 .handle("close", json!({ "path": wt_path, "remove": true }))
12097 .await
12098 .unwrap();
12099 let info = report.get("info").and_then(Value::as_array).unwrap();
12102 assert!(
12103 info.iter()
12104 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12105 "expected an unpushed info note: {report}"
12106 );
12107 assert!(
12108 report
12109 .get("risks")
12110 .and_then(Value::as_array)
12111 .unwrap()
12112 .is_empty(),
12113 "unpushed commits alone must not block: {report}"
12114 );
12115 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12116 }
12117
12118 #[tokio::test]
12119 async fn close_safety_check_ignores_gitignored_files() {
12120 let main_dir = tempfile::tempdir().unwrap();
12124 let repo = init_repo(main_dir.path());
12125 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12126 repo.set_head("refs/heads/trunk").unwrap();
12127 let wt_parent = tempfile::tempdir().unwrap();
12128 let wt_path = wt_parent.path().join("feature-wt");
12129 add_worktree(&repo, a, &wt_path, "feature");
12130 std::fs::create_dir(wt_path.join("build")).unwrap();
12131 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12132
12133 let svc = WorktreesService::new();
12134 let report = svc
12135 .handle("close", json!({ "path": wt_path, "remove": true }))
12136 .await
12137 .unwrap();
12138 assert!(
12139 report
12140 .get("risks")
12141 .and_then(Value::as_array)
12142 .unwrap()
12143 .is_empty(),
12144 "a gitignored file must not create a risk: {report}"
12145 );
12146 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12147 }
12148
12149 #[tokio::test]
12150 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12151 let svc = WorktreesService::new();
12154 let report = svc
12155 .handle(
12156 "close",
12157 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12158 )
12159 .await
12160 .unwrap();
12161 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12162 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12163 assert!(report
12164 .get("risks")
12165 .and_then(Value::as_array)
12166 .unwrap()
12167 .is_empty());
12168 let info = report.get("info").and_then(Value::as_array).unwrap();
12169 assert!(info
12170 .iter()
12171 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12172 }
12173
12174 #[tokio::test]
12175 async fn close_phase1_errors_on_a_non_git_worktree_path() {
12176 let dir = tempfile::tempdir().unwrap();
12181 let svc = WorktreesService::new();
12182 let result = svc
12183 .handle("close", json!({ "path": dir.path(), "remove": true }))
12184 .await;
12185 assert!(
12186 result.is_err(),
12187 "a non-git-worktree target must error the safety check, got: {result:?}"
12188 );
12189 }
12190
12191 #[tokio::test]
12192 async fn close_refuses_a_locked_worktree() {
12193 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12196 let main_repo = Repository::open(main.path()).unwrap();
12197 main_repo
12198 .find_worktree("feature")
12199 .unwrap()
12200 .lock(Some("under test"))
12201 .unwrap();
12202
12203 let svc = WorktreesService::new();
12204 let err = svc
12205 .handle(
12206 "close",
12207 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12208 )
12209 .await
12210 .unwrap_err();
12211 assert!(
12212 err.to_string().contains("locked"),
12213 "expected a locked error: {err}"
12214 );
12215 assert!(wt_path.exists(), "a locked worktree must not be removed");
12216 }
12217
12218 #[tokio::test]
12219 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12220 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12224 let wt_repo = Repository::open(&wt_path).unwrap();
12225 let tip = wt_repo.head().unwrap().target().unwrap();
12228 wt_repo.set_head_detached(tip).unwrap();
12229 assert!(wt_repo.head_detached().unwrap());
12230
12231 let svc = WorktreesService::new();
12232 let report = svc
12233 .handle("close", json!({ "path": wt_path, "remove": true }))
12234 .await
12235 .unwrap();
12236 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12237 assert!(
12238 !risks
12239 .iter()
12240 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12241 "a detached HEAD reachable from a branch must not be flagged: {report}"
12242 );
12243 }
12244
12245 #[test]
12246 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12247 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12248 let main_repo = Repository::open(main.path()).unwrap();
12249 assert_eq!(
12251 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12252 "feature"
12253 );
12254 let err =
12257 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12258 assert!(
12259 err.to_string().contains("not registered"),
12260 "expected a not-registered error: {err}"
12261 );
12262 }
12263
12264 #[test]
12265 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12266 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12269 let repo = Repository::open(&wt_path).unwrap();
12270 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12271 assert!(
12274 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12275 "a corrupt index should make statuses() fail"
12276 );
12277 assert_eq!(count_dirty_untracked(&repo), (0, 0));
12278 }
12279}