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.map_or(true, |elapsed| elapsed >= backoff)
250}
251
252fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
267 next.iter().any(|w| !prev.contains(w))
268}
269
270fn next_pr_poll_delay(
288 current: Duration,
289 base: Duration,
290 pending: bool,
291 since_moved: Option<Duration>,
292) -> Duration {
293 if !pending {
294 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
295 }
296 match since_moved {
297 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
299 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
302 }
303}
304
305fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
317 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
318 delay.max(BUDGET_THROTTLE_INTERVAL)
319 } else {
320 delay
321 }
322}
323
324fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
332 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
333 [
337 (prev.and_then(|p| p.graphql), next.graphql),
338 (prev.and_then(|p| p.core), next.core),
339 (prev.and_then(|p| p.search), next.search),
340 ]
341 .into_iter()
342 .any(|(before, after)| over(after) && !over(before))
343}
344
345struct PollerTask {
347 token: CancellationToken,
349 handle: JoinHandle<()>,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
370struct PrWatch {
371 target: PrTarget,
373 upstream_sha: Option<String>,
375}
376
377fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
386 let mut out = Vec::new();
387 for repo in snapshot
388 .get("repos")
389 .and_then(Value::as_array)
390 .into_iter()
391 .flatten()
392 {
393 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
398 continue;
399 }
400 let Some(github) = repo.get("github") else {
401 continue;
402 };
403 let (Some(owner), Some(name)) = (
404 github.get("owner").and_then(Value::as_str),
405 github.get("name").and_then(Value::as_str),
406 ) else {
407 continue;
408 };
409 for wt in repo
410 .get("worktrees")
411 .and_then(Value::as_array)
412 .into_iter()
413 .flatten()
414 {
415 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
416 out.push(PrWatch {
417 upstream_sha: wt
418 .get("upstream_sha")
419 .and_then(Value::as_str)
420 .map(str::to_string),
421 target: PrTarget {
422 owner: owner.to_string(),
423 name: name.to_string(),
424 branch: branch.to_string(),
425 },
426 });
427 }
428 }
429 }
430 out.sort();
431 out.dedup();
432 out
433}
434
435#[cfg(test)]
438fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
439 pr_watch_from_snapshot(snapshot)
440 .into_iter()
441 .map(|w| w.target)
442 .collect()
443}
444
445pub struct WorktreesService {
447 registry: Arc<WorktreesRegistry>,
450 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
456 refresh: Mutex<Option<RefreshTask>>,
458 pr_cache: Arc<PrStatusCache>,
464 poller: Mutex<Option<PollerTask>>,
467 rate_limit_cache: Arc<RateLimitCache>,
475 rate_limit_poller: Mutex<Option<PollerTask>>,
478 tree_cache: Arc<TreeSnapshotCache>,
484 prune_lock: tokio::sync::Mutex<()>,
501 polling_prefs_path: Mutex<Option<PathBuf>>,
509 pr_cache_path: Mutex<Option<PathBuf>>,
516 pr_warm_start: Mutex<Option<PrWarmStart>>,
521 open_pr_cache: Arc<OpenPrCache>,
526 reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
542 rebase_lock: tokio::sync::Mutex<()>,
554 push_lock: tokio::sync::Mutex<()>,
571}
572
573impl WorktreesService {
574 #[must_use]
579 pub fn new() -> Self {
580 let registry = Arc::new(WorktreesRegistry::new());
581 let pr_cache = Arc::new(PrStatusCache::new());
582 Self {
583 registry: registry.clone(),
584 menu_cache: Arc::new(Mutex::new(None)),
585 refresh: Mutex::new(None),
586 pr_cache: pr_cache.clone(),
587 poller: Mutex::new(None),
588 rate_limit_cache: Arc::new(RateLimitCache::new()),
589 rate_limit_poller: Mutex::new(None),
590 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
591 prune_lock: tokio::sync::Mutex::new(()),
592 polling_prefs_path: Mutex::new(None),
593 pr_cache_path: Mutex::new(None),
594 pr_warm_start: Mutex::new(None),
595 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
596 reposition_undo: Mutex::new(Vec::new()),
597 rebase_lock: tokio::sync::Mutex::new(()),
598 push_lock: tokio::sync::Mutex::new(()),
599 }
600 }
601
602 pub fn load_polling_prefs(&self, path: PathBuf) {
611 match std::fs::read(&path) {
612 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
613 Ok(prefs) => self
614 .registry
615 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
616 Err(err) => tracing::warn!(
617 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
618 path.display()
619 ),
620 },
621 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
622 Err(err) => tracing::warn!(
623 "could not read worktrees polling prefs at {}: {err:#}",
624 path.display()
625 ),
626 }
627 *self
628 .polling_prefs_path
629 .lock()
630 .unwrap_or_else(PoisonError::into_inner) = Some(path);
631 }
632
633 fn persist_polling_prefs(&self) {
640 let Some(path) = self
641 .polling_prefs_path
642 .lock()
643 .unwrap_or_else(PoisonError::into_inner)
644 .clone()
645 else {
646 return;
647 };
648 let prefs = PollingPrefs {
649 enabled: self
650 .registry
651 .polling_snapshot()
652 .into_iter()
653 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
654 .collect(),
655 };
656 if let Err(err) = write_polling_prefs(&path, &prefs) {
657 tracing::warn!(
658 "could not persist worktrees polling prefs to {}: {err:#}",
659 path.display()
660 );
661 }
662 }
663
664 pub fn load_pr_cache(&self, path: PathBuf) {
677 match std::fs::read(&path) {
678 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
679 Ok(prefs) => {
680 self.pr_cache.seed(
681 prefs
682 .entries
683 .into_iter()
684 .map(|e| (e.target, e.resolution.into_resolution())),
685 );
686 if let Some(polled_at) = prefs.polled_at {
691 let watched = prefs
692 .watched
693 .into_iter()
694 .map(|w| PrWatch {
695 target: w.target,
696 upstream_sha: w.upstream_sha,
697 })
698 .collect();
699 *self
700 .pr_warm_start
701 .lock()
702 .unwrap_or_else(PoisonError::into_inner) =
703 Some(PrWarmStart { watched, polled_at });
704 }
705 }
706 Err(err) => {
710 let at = path.display();
711 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
712 }
713 },
714 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
715 Err(err) => {
716 let at = path.display();
717 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
718 }
719 }
720 *self
721 .pr_cache_path
722 .lock()
723 .unwrap_or_else(PoisonError::into_inner) = Some(path);
724 }
725
726 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
733 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
737 .await
738 }
739
740 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
743 let key = format!("{owner}/{name}");
744 if let Some(prs) = self.open_pr_cache.fresh(&key) {
745 return Ok(prs);
746 }
747 let slug = key.clone();
748 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
749 .await
750 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
751 self.open_pr_cache.store(key, prs.clone());
752 Ok(prs)
753 }
754
755 #[must_use]
759 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
760 self.rate_limit_cache.clone()
761 }
762
763 pub fn start_menu_refresh(&self) {
771 if tokio::runtime::Handle::try_current().is_err() {
772 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
773 return;
774 }
775 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
776 if guard.is_some() {
777 return;
778 }
779 let token = CancellationToken::new();
780 let loop_token = token.clone();
781 let registry = self.registry.clone();
782 let cache = self.menu_cache.clone();
783 let rate_limit_cache = self.rate_limit_cache.clone();
784 let interval = menu_refresh_interval();
787 let handle = tokio::spawn(async move {
788 loop {
789 let entries = registry.list();
793 let rate_limit = rate_limit_cache.get();
796 if let Ok(items) = tokio::task::spawn_blocking(move || {
797 menu_items_for(&entries, rate_limit.as_ref())
798 })
799 .await
800 {
801 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
802 }
803 tokio::select! {
804 () = loop_token.cancelled() => break,
805 () = tokio::time::sleep(interval) => {}
806 }
807 }
808 });
809 *guard = Some(RefreshTask { token, handle });
810 }
811
812 pub fn start_pr_poller(&self) {
843 self.start_pr_poller_with(
846 pr_poll_interval(),
847 pr_debounce_interval(),
848 crate::pr_status::resolve_gh_binary(),
849 );
850 }
851
852 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
863 if tokio::runtime::Handle::try_current().is_err() {
864 tracing::debug!("no tokio runtime; worktrees PR poller not started");
865 return;
866 }
867 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
868 if guard.is_some() {
869 return;
870 }
871 let token = CancellationToken::new();
872 let loop_token = token.clone();
873 let registry = self.registry.clone();
874 let tree_cache = self.tree_cache.clone();
875 let pr_cache = self.pr_cache.clone();
876 let rate_limit_cache = self.rate_limit_cache.clone();
880 let pr_cache_path = self
881 .pr_cache_path
882 .lock()
883 .unwrap_or_else(PoisonError::into_inner)
884 .clone();
885 let warm_start = self
886 .pr_warm_start
887 .lock()
888 .unwrap_or_else(PoisonError::into_inner)
889 .take();
890 let mut changes = self.registry.subscribe_changes();
893 let handle = tokio::spawn(async move {
894 let mut backoff = base;
905 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
912 match warm_start {
913 Some(ws) => {
914 let elapsed = (Utc::now() - ws.polled_at)
915 .to_std()
916 .unwrap_or(Duration::ZERO);
917 (Some(ws.watched), Instant::now().checked_sub(elapsed))
918 }
919 None => (None, None),
920 };
921 let mut moved_at: Option<Instant> = None;
924 'poll: loop {
925 tokio::select! {
928 () = loop_token.cancelled() => break,
929 () = tokio::time::sleep(base) => {}
930 result = changes.changed() => {
933 if result.is_err() {
940 break;
941 }
942 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
950 loop {
951 tokio::select! {
952 () = loop_token.cancelled() => break 'poll,
953 () = tokio::time::sleep(debounce) => break,
954 r = changes.changed() => {
955 if r.is_err() {
956 break 'poll;
957 }
958 if Instant::now() >= overall_deadline {
959 break;
960 }
961 }
962 }
963 }
964 }
965 }
966 let snapshot = tree_cache.snapshot().await;
969 let watch = pr_watch_from_snapshot(&snapshot);
970 if watch.is_empty() {
971 backoff = base;
978 last_poll = None;
979 moved_at = None;
980 continue;
981 }
982 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
986 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
990 pr_cache.retain_targets(&keep);
991 let rate_limit = rate_limit_cache.get();
997 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
998 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
999 let trigger = grew && !over_budget;
1000 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1001 if !grew {
1006 watched = Some(watch);
1007 }
1008 continue;
1009 }
1010 if grew {
1011 backoff = base;
1014 moved_at = Some(Instant::now());
1015 }
1016 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1017 let bin = gh_bin.clone();
1022 let resolved = tokio::task::spawn_blocking(move || {
1023 crate::pr_status::resolve_with_budget(&bin, &targets)
1024 })
1025 .await
1026 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1027 let (pending, resolved_ok) = match resolved {
1034 Ok((resolutions, budget)) => {
1035 if let Some(b) = budget {
1041 tracing::debug!(
1042 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1043 b.cost,
1044 b.used,
1045 b.limit,
1046 b.remaining
1047 );
1048 rate_limit_cache.observe_graphql(RateLimitResource::new(
1049 b.used,
1050 b.limit,
1051 b.remaining,
1052 b.reset,
1053 ));
1054 }
1055 if pr_cache.replace(resolutions) {
1058 registry.bump();
1059 }
1060 (pr_cache.any_pending(), true)
1061 }
1062 Err(err) => {
1063 tracing::debug!("PR badge poll failed: {err:#}");
1064 (false, false)
1065 }
1066 };
1067 last_poll = Some(Instant::now());
1068 watched = Some(watch);
1071 let since_moved = moved_at.map(|at| at.elapsed());
1072 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1073 if resolved_ok {
1079 if let Some(path) = &pr_cache_path {
1080 persist_pr_cache(
1081 path,
1082 &pr_cache,
1083 watched.as_deref().unwrap_or(&[]),
1084 Utc::now(),
1085 );
1086 }
1087 }
1088 }
1089 });
1090 *guard = Some(PollerTask { token, handle });
1091 }
1092
1093 pub fn start_rate_limit_poller(&self) {
1111 self.start_rate_limit_poller_with(
1114 rate_limit_poll_interval(),
1115 crate::pr_status::resolve_gh_binary(),
1116 );
1117 }
1118
1119 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1124 if tokio::runtime::Handle::try_current().is_err() {
1125 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1126 return;
1127 }
1128 let mut guard = self
1129 .rate_limit_poller
1130 .lock()
1131 .unwrap_or_else(PoisonError::into_inner);
1132 if guard.is_some() {
1133 return;
1134 }
1135 let token = CancellationToken::new();
1136 let loop_token = token.clone();
1137 let cache = self.rate_limit_cache.clone();
1138 let registry = self.registry.clone();
1139 let handle = tokio::spawn(async move {
1140 let mut prev: Option<RateLimitSnapshot> = None;
1143 loop {
1144 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1152 let bin = gh_bin.clone();
1158 let resolved =
1159 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1160 .await
1161 .unwrap_or_else(|err| {
1162 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1163 });
1164 match resolved {
1165 Ok(snap) => {
1166 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1167 let summary = snap.summary_line();
1173 tracing::warn!(
1174 "GitHub API rate limit high: {summary} (querying \
1175 /rate_limit is free; the daemon's gh usage is not)"
1176 );
1177 }
1178 cache.replace(snap);
1183 prev = Some(snap);
1184 }
1185 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1190 }
1191 }
1192 tokio::select! {
1193 () = loop_token.cancelled() => break,
1194 () = tokio::time::sleep(interval) => {}
1195 }
1196 }
1197 });
1198 *guard = Some(PollerTask { token, handle });
1199 }
1200
1201 async fn close(&self, req: CloseRequest) -> Result<Value> {
1216 let entries = self.registry.list();
1220 let scan_path = req.path.clone();
1221 let open_windows =
1222 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1223 .await
1224 .unwrap_or_default();
1225 let open = !open_windows.is_empty();
1226 let window_key = open_windows.first().map(|(k, _)| k.clone());
1227 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1228
1229 if req.remove && !req.confirmed {
1233 let path = req.path.clone();
1234 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1235 .await
1236 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1237 .and_then(|inner| inner)
1238 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1239 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1245 return Ok(serde_json::to_value(SafetyReport {
1246 removable: git.removable,
1247 is_main: git.is_main,
1248 open,
1249 window_key,
1250 window_folder_count,
1251 risks: git.risks,
1252 info: git.info,
1253 })
1254 .unwrap_or_else(|_| json!({})));
1255 }
1256
1257 let others: Vec<String> = open_windows
1264 .iter()
1265 .map(|(k, _)| k.clone())
1266 .filter(|k| req.requester_key.as_deref() != Some(k))
1267 .collect();
1268 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1273 log_executing(
1274 &req.path,
1275 req.requester_key.as_deref(),
1276 req.remove,
1277 self_close,
1278 others.len(),
1279 );
1280 for key in &others {
1281 self.registry.mark_close_pending(key);
1282 }
1283 if !others.is_empty() {
1284 if let Err(err) = await_windows_closed(
1285 &self.registry,
1286 &req.path,
1287 req.requester_key.as_deref(),
1288 CLOSE_WAIT_TIMEOUT,
1289 CLOSE_WAIT_POLL,
1290 )
1291 .await
1292 {
1293 log_close_abort(&req.path, &err);
1294 return Err(err);
1295 }
1296 }
1297
1298 if req.remove {
1299 let path = req.path.clone();
1300 let entries = self.registry.list();
1304 let _guard = self.prune_lock.lock().await;
1310 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1311 .await
1312 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1313 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1314 log_and_map_removal(&req.path, removed)
1317 } else {
1318 log_window_closed(&req.path);
1321 Ok(json!({ "closed": true }))
1322 }
1323 }
1324
1325 fn reload(&self, req: ReloadRequest) -> Value {
1341 let live: HashSet<String> = self
1342 .registry
1343 .list()
1344 .into_iter()
1345 .map(|entry| entry.key)
1346 .collect();
1347
1348 let mut seen = HashSet::new();
1349 let mut signalled = 0usize;
1350 let mut unknown = Vec::new();
1351 for key in &req.target_keys {
1352 if !seen.insert(key.as_str()) {
1354 continue;
1355 }
1356 if live.contains(key) {
1357 self.registry.mark_reload_pending(key);
1358 signalled += 1;
1359 } else {
1360 unknown.push(key.clone());
1361 }
1362 }
1363
1364 log_reload(seen.len(), signalled, &unknown);
1365 json!({
1366 "requested": seen.len(),
1367 "signalled": signalled,
1368 "unknown": unknown,
1369 })
1370 }
1371
1372 async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1388 self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1393 .await
1394 }
1395
1396 async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1399 let report_only = req.check || !req.confirmed;
1402
1403 let eval_bin = bin.clone();
1404 let eval_paths = req.paths.clone();
1405 let (eligible, skipped) =
1406 tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1407 .await
1408 .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1409 .and_then(|inner| inner)?;
1410
1411 if report_only {
1412 log_merge_check(&req, eligible.len(), skipped.len());
1414 let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1415 return Ok(
1416 serde_json::to_value(EligibilityReport { eligible, skipped })
1417 .unwrap_or_else(|_| json!({})),
1418 );
1419 }
1420
1421 let enqueue_bin = bin.clone();
1423 let (queued, failed) =
1424 tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1425 .await
1426 .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1427 log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1428 Ok(serde_json::to_value(EnqueueResult {
1429 queued,
1430 skipped,
1431 failed,
1432 })
1433 .unwrap_or_else(|_| json!({})))
1434 }
1435
1436 async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1462 self.rebase_with(req, crate::git::resolve_git_binary())
1466 .await
1467 }
1468
1469 async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1472 if req.paths.is_empty() {
1473 bail!("`rebase` requires at least one path");
1474 }
1475 let report_only = req.check || !req.confirmed;
1478 let opts = req.options(git_bin);
1479 let selection = Selection::Paths(req.paths.clone());
1480
1481 if report_only {
1482 let plan = plan_rebase(&selection, &opts).await?;
1485 log_rebase_check(&req, &plan);
1487 return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1488 }
1489
1490 let _guard = self.rebase_lock.lock().await;
1503 let plan = plan_rebase(&selection, &opts).await?;
1504 let pending: Vec<PathBuf> = plan
1508 .worktrees
1509 .iter()
1510 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1511 .map(|w| canonical(&w.path))
1512 .collect();
1513 self.registry.mark_rebasing(&pending);
1514 let fetches = plan.fetches.clone();
1515 let exec_opts = opts.clone();
1516 let outcomes =
1517 tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1518 self.registry.clear_rebasing(&pending);
1521 let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1522
1523 log_rebase_execute(&req, &outcomes);
1524 Ok(rebase_reply(&fetches, &outcomes))
1525 }
1526
1527 async fn push(&self, req: PushRequest) -> Result<Value> {
1556 self.push_with(req, crate::git::resolve_git_binary()).await
1559 }
1560
1561 async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1564 if req.paths.is_empty() {
1565 bail!("`push` requires at least one path");
1566 }
1567 let report_only = req.check || !req.confirmed;
1570 let selection = Selection::Paths(req.paths.clone());
1571
1572 if report_only {
1573 let plan = plan_push(&selection).await?;
1577 log_push_check(&req, &plan);
1579 return Ok(push_reply(&plan.worktrees));
1580 }
1581
1582 let _guard = self.push_lock.lock().await;
1588 let plan = plan_push(&selection).await?;
1589 let pending: Vec<PathBuf> = plan
1593 .worktrees
1594 .iter()
1595 .filter(|w| w.result.is_pending())
1596 .map(|w| canonical(&w.path))
1597 .collect();
1598 self.registry.mark_pushing(&pending);
1599 let opts = worktree_push::PushOptions {
1600 git_bin: Some(git_bin),
1601 };
1602 let outcomes =
1603 tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1604 self.registry.clear_pushing(&pending);
1608 let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1609
1610 log_push_execute(&req, &outcomes);
1611 Ok(push_reply(&outcomes))
1612 }
1613
1614 async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1632 self.reposition_with(req, geometry::ax::AxBackend::new)
1633 .await
1634 }
1635
1636 async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1645 where
1646 B: geometry::WindowBackend,
1647 F: FnOnce() -> B + Send + 'static,
1648 {
1649 if req.reference_key.trim().is_empty() {
1650 bail!("`reposition` requires a non-empty `reference_key`");
1651 }
1652 let entries = self.registry.list();
1655 let reference = registered_window(&entries, &req.reference_key);
1656 if !reference.live {
1657 bail!(
1660 "no open window with key {} (it may have closed)",
1661 req.reference_key
1662 );
1663 }
1664 let targets: Vec<geometry::RegisteredWindow> = req
1667 .target_keys
1668 .iter()
1669 .map(|key| registered_window(&entries, key))
1670 .collect();
1671
1672 let check = req.check;
1673 let mut report = tokio::task::spawn_blocking(move || {
1674 let backend = make_backend();
1677 geometry::reposition(&backend, &reference, &targets, check)
1678 })
1679 .await
1680 .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1681
1682 let undo = std::mem::take(&mut report.undo);
1687 let undoable = !check && !undo.is_empty();
1688 if undoable {
1689 *self
1690 .reposition_undo
1691 .lock()
1692 .unwrap_or_else(PoisonError::into_inner) = undo;
1693 }
1694 log_reposition(&req, &report);
1695 Ok(reposition_reply(&report, undoable))
1696 }
1697
1698 async fn reposition_undo(&self) -> Result<Value> {
1706 self.reposition_undo_with(geometry::ax::AxBackend::new)
1707 .await
1708 }
1709
1710 async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1714 where
1715 B: geometry::WindowBackend,
1716 F: FnOnce() -> B + Send + 'static,
1717 {
1718 let stored = std::mem::take(
1719 &mut *self
1720 .reposition_undo
1721 .lock()
1722 .unwrap_or_else(PoisonError::into_inner),
1723 );
1724 if stored.is_empty() {
1725 return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1726 }
1727 let entries = self.registry.list();
1728 let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1729 .into_iter()
1730 .map(|(key, frame)| (registered_window(&entries, &key), frame))
1731 .collect();
1732
1733 let report = tokio::task::spawn_blocking(move || {
1734 let backend = make_backend();
1735 geometry::restore(&backend, &restore)
1736 })
1737 .await
1738 .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1739 log_reposition_undo(&report);
1740 Ok(reposition_reply(&report, false))
1741 }
1742}
1743
1744impl Default for WorktreesService {
1745 fn default() -> Self {
1746 Self::new()
1747 }
1748}
1749
1750#[async_trait]
1751impl DaemonService for WorktreesService {
1752 fn name(&self) -> &'static str {
1753 SERVICE_NAME
1754 }
1755
1756 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1757 match op {
1758 "register" => {
1759 let req: RegisterRequest =
1760 serde_json::from_value(payload).context("invalid `register` payload")?;
1761 if req.key.trim().is_empty() {
1762 bail!("`register` requires a non-empty `key`");
1763 }
1764 self.registry.register(req);
1765 Ok(json!({ "ok": true }))
1766 }
1767 "heartbeat" => {
1768 let key = require_str(&payload, "key", "heartbeat")?;
1769 let known = self.registry.heartbeat(key);
1770 let mut reply = json!({ "known": known });
1775 if self.registry.take_close_pending(key) {
1776 reply["close"] = Value::Bool(true);
1777 }
1778 if self.registry.take_reload_pending(key) {
1785 reply["reload"] = Value::Bool(true);
1786 }
1787 Ok(reply)
1788 }
1789 "unregister" => {
1790 let key = require_str(&payload, "key", "unregister")?;
1791 Ok(json!({ "removed": self.registry.unregister(key) }))
1792 }
1793 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1794 "tree" => {
1795 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1803 }
1804 "ahead-behind" => {
1805 let paths = payload
1812 .get("paths")
1813 .and_then(Value::as_array)
1814 .map(|arr| {
1815 arr.iter()
1816 .filter_map(Value::as_str)
1817 .map(PathBuf::from)
1818 .collect::<Vec<_>>()
1819 })
1820 .unwrap_or_default();
1821 Ok(json!({ "results": ahead_behind_results(paths).await }))
1822 }
1823 "set-show-closed" => {
1824 let show_closed = payload
1829 .get("show_closed")
1830 .and_then(Value::as_bool)
1831 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1832 self.registry.set_show_closed(show_closed);
1833 Ok(json!({ "ok": true }))
1834 }
1835 "set-polling" => {
1836 let owner = require_str(&payload, "owner", "set-polling")?;
1845 let name = require_str(&payload, "name", "set-polling")?;
1846 let enabled = payload
1847 .get("enabled")
1848 .and_then(Value::as_bool)
1849 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1850 if owner.trim().is_empty() || name.trim().is_empty() {
1851 bail!("`set-polling` requires a non-empty `owner` and `name`");
1852 }
1853 if self.registry.set_polling(owner, name, enabled) {
1854 self.persist_polling_prefs();
1855 }
1856 Ok(json!({ "ok": true }))
1857 }
1858 "open-prs" => {
1859 let owner = require_str(&payload, "owner", "open-prs")?;
1867 let name = require_str(&payload, "name", "open-prs")?;
1868 if owner.trim().is_empty() || name.trim().is_empty() {
1869 bail!("`open-prs` requires a non-empty `owner` and `name`");
1870 }
1871 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1872 }
1873 "open" => {
1874 let path = require_str(&payload, "path", "open")?;
1883 focus_window(Path::new(path))?;
1884 Ok(json!({ "ok": true }))
1885 }
1886 "close" => {
1887 let req: CloseRequest =
1893 serde_json::from_value(payload).context("invalid `close` payload")?;
1894 self.close(req).await
1895 }
1896 "reload" => {
1897 let req: ReloadRequest =
1905 serde_json::from_value(payload).context("invalid `reload` payload")?;
1906 Ok(self.reload(req))
1907 }
1908 "merge-queue" => {
1909 let req: MergeQueueRequest =
1916 serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1917 self.merge_queue(req).await
1918 }
1919 "rebase" => {
1920 let req: RebaseRequest =
1929 serde_json::from_value(payload).context("invalid `rebase` payload")?;
1930 self.rebase(req).await
1931 }
1932 "push" => {
1933 let req: PushRequest =
1943 serde_json::from_value(payload).context("invalid `push` payload")?;
1944 self.push(req).await
1945 }
1946 "reposition" => {
1947 let req: RepositionRequest =
1955 serde_json::from_value(payload).context("invalid `reposition` payload")?;
1956 self.reposition(req).await
1957 }
1958 "reposition-undo" => {
1959 self.reposition_undo().await
1963 }
1964 other => bail!("unknown worktrees op: {other}"),
1965 }
1966 }
1967
1968 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1969 if op != "subscribe" {
1972 return None;
1973 }
1974 Some(Box::new(WorktreesStream {
1975 cache: self.tree_cache.clone(),
1978 changes: self.registry.subscribe_changes(),
1981 }))
1982 }
1983
1984 fn menu(&self) -> MenuSnapshot {
1985 let cached = self
1990 .menu_cache
1991 .lock()
1992 .unwrap_or_else(PoisonError::into_inner)
1993 .clone();
1994 let items = cached.unwrap_or_else(|| {
1995 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1996 });
1997 MenuSnapshot {
1998 title: SUBMENU_TITLE.to_string(),
1999 items,
2000 }
2001 }
2002
2003 async fn menu_action(&self, action_id: &str) -> Result<()> {
2004 if let Some(key) = action_id.strip_prefix("focus:") {
2005 let folder = self
2008 .registry
2009 .first_folder(key)
2010 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2011 focus_window(&folder)?;
2012 return Ok(());
2013 }
2014 bail!("unknown worktrees menu action: {action_id}")
2015 }
2016
2017 async fn status(&self) -> ServiceStatus {
2018 let entries = self.registry.list();
2019 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2020 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2021 let windows = enriched_windows(entries).await;
2022 ServiceStatus {
2023 name: SERVICE_NAME.to_string(),
2024 healthy: true,
2025 summary,
2026 detail: json!({ "windows": windows }),
2027 }
2028 }
2029
2030 async fn shutdown(&self) {
2031 let task = self
2035 .refresh
2036 .lock()
2037 .unwrap_or_else(PoisonError::into_inner)
2038 .take();
2039 if let Some(task) = task {
2040 task.token.cancel();
2041 let _ = task.handle.await;
2042 }
2043 let poller = self
2046 .poller
2047 .lock()
2048 .unwrap_or_else(PoisonError::into_inner)
2049 .take();
2050 if let Some(poller) = poller {
2051 poller.token.cancel();
2052 let _ = poller.handle.await;
2053 }
2054 let rate_limit_poller = self
2056 .rate_limit_poller
2057 .lock()
2058 .unwrap_or_else(PoisonError::into_inner)
2059 .take();
2060 if let Some(poller) = rate_limit_poller {
2061 poller.token.cancel();
2062 let _ = poller.handle.await;
2063 }
2064 }
2065}
2066
2067fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2071 payload
2072 .get(field)
2073 .and_then(Value::as_str)
2074 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2075}
2076
2077#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2088struct GitStatus {
2089 #[serde(skip_serializing_if = "Option::is_none")]
2091 branch: Option<String>,
2092 #[serde(skip_serializing_if = "Option::is_none")]
2098 head_sha: Option<String>,
2099 #[serde(skip_serializing_if = "Option::is_none")]
2107 upstream_sha: Option<String>,
2108 #[serde(skip_serializing_if = "Option::is_none")]
2110 ahead: Option<usize>,
2111 #[serde(skip_serializing_if = "Option::is_none")]
2113 behind: Option<usize>,
2114 #[serde(skip_serializing_if = "Option::is_none")]
2119 main_repo: Option<String>,
2120 #[serde(skip_serializing_if = "is_false")]
2123 is_worktree: bool,
2124 #[serde(skip_serializing_if = "Option::is_none")]
2140 operation: Option<String>,
2141}
2142
2143#[allow(clippy::trivially_copy_pass_by_ref)]
2147fn is_false(b: &bool) -> bool {
2148 !*b
2149}
2150
2151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2156struct PollingLease {
2157 repo: String,
2158 expires_at: DateTime<Utc>,
2159}
2160
2161#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2167struct PollingPrefs {
2168 #[serde(default)]
2169 enabled: Vec<PollingLease>,
2170}
2171
2172fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2176 if let Some(parent) = path.parent() {
2177 crate::daemon::paths::ensure_dir_0700(parent)?;
2178 }
2179 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2180 crate::daemon::paths::write_file_0600(path, &json)
2181}
2182
2183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2196struct PersistedBadge {
2197 number: u64,
2198 is_draft: bool,
2199 checks: PrCheckState,
2200 url: String,
2201 head_oid: String,
2202}
2203
2204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2209enum PersistedResolution {
2210 Pr(PersistedBadge),
2211 NoPr,
2212}
2213
2214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2216struct PersistedEntry {
2217 target: PrTarget,
2218 resolution: PersistedResolution,
2219}
2220
2221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2226struct PersistedWatch {
2227 target: PrTarget,
2228 #[serde(default, skip_serializing_if = "Option::is_none")]
2229 upstream_sha: Option<String>,
2230}
2231
2232#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2237struct PrCachePrefs {
2238 #[serde(default)]
2239 entries: Vec<PersistedEntry>,
2240 #[serde(default)]
2241 watched: Vec<PersistedWatch>,
2242 #[serde(default, skip_serializing_if = "Option::is_none")]
2243 polled_at: Option<DateTime<Utc>>,
2244}
2245
2246impl PersistedResolution {
2247 fn from_resolution(r: &PrResolution) -> Self {
2249 match r {
2250 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2251 number: b.number,
2252 is_draft: b.is_draft,
2253 checks: b.checks,
2254 url: b.url.clone(),
2255 head_oid: b.head_oid.clone(),
2256 }),
2257 PrResolution::NoPr => Self::NoPr,
2258 }
2259 }
2260
2261 fn into_resolution(self) -> PrResolution {
2263 match self {
2264 Self::Pr(b) => PrResolution::Pr(PrBadge {
2265 number: b.number,
2266 is_draft: b.is_draft,
2267 checks: b.checks,
2268 url: b.url,
2269 head_oid: b.head_oid,
2270 }),
2271 Self::NoPr => PrResolution::NoPr,
2272 }
2273 }
2274}
2275
2276fn pr_cache_prefs_from(
2279 entries: Vec<(PrTarget, PrResolution)>,
2280 watched: &[PrWatch],
2281 polled_at: DateTime<Utc>,
2282) -> PrCachePrefs {
2283 let mut entries: Vec<PersistedEntry> = entries
2284 .into_iter()
2285 .map(|(target, resolution)| PersistedEntry {
2286 target,
2287 resolution: PersistedResolution::from_resolution(&resolution),
2288 })
2289 .collect();
2290 entries.sort_by(|a, b| a.target.cmp(&b.target));
2293 let mut watched: Vec<PersistedWatch> = watched
2294 .iter()
2295 .map(|w| PersistedWatch {
2296 target: w.target.clone(),
2297 upstream_sha: w.upstream_sha.clone(),
2298 })
2299 .collect();
2300 watched.sort_by(|a, b| a.target.cmp(&b.target));
2301 PrCachePrefs {
2302 entries,
2303 watched,
2304 polled_at: Some(polled_at),
2305 }
2306}
2307
2308fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2311 if let Some(parent) = path.parent() {
2312 crate::daemon::paths::ensure_dir_0700(parent)?;
2313 }
2314 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2315 crate::daemon::paths::write_file_0600(path, &json)
2316}
2317
2318fn persist_pr_cache(
2324 path: &Path,
2325 pr_cache: &PrStatusCache,
2326 watched: &[PrWatch],
2327 polled_at: DateTime<Utc>,
2328) {
2329 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2330 if let Err(err) = write_pr_cache(path, &prefs) {
2331 let at = path.display();
2332 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2333 }
2334}
2335
2336#[derive(Debug, Clone)]
2342struct PrWarmStart {
2343 watched: Vec<PrWatch>,
2345 polled_at: DateTime<Utc>,
2348}
2349
2350#[derive(Debug, Clone)]
2355struct OpenPrEntry {
2356 at: Instant,
2357 prs: Vec<Value>,
2358}
2359
2360#[derive(Debug)]
2371struct OpenPrCache {
2372 entries: Mutex<HashMap<String, OpenPrEntry>>,
2373 ttl: Duration,
2374}
2375
2376impl OpenPrCache {
2377 fn new(ttl: Duration) -> Self {
2378 Self {
2379 entries: Mutex::new(HashMap::new()),
2380 ttl,
2381 }
2382 }
2383
2384 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2387 self.entries
2388 .lock()
2389 .unwrap_or_else(PoisonError::into_inner)
2390 .get(key)
2391 .filter(|e| e.at.elapsed() < self.ttl)
2392 .map(|e| e.prs.clone())
2393 }
2394
2395 fn store(&self, key: String, prs: Vec<Value>) {
2397 self.entries
2398 .lock()
2399 .unwrap_or_else(PoisonError::into_inner)
2400 .insert(
2401 key,
2402 OpenPrEntry {
2403 at: Instant::now(),
2404 prs,
2405 },
2406 );
2407 }
2408}
2409
2410fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2415 let output = crate::github_metrics::run_gh(
2416 bin,
2417 [
2418 "pr",
2419 "list",
2420 "--repo",
2421 slug,
2422 "--state",
2423 "open",
2424 "--json",
2425 OPEN_PR_JSON_FIELDS,
2426 "--limit",
2427 OPEN_PR_LIST_LIMIT,
2428 ],
2429 "pr list",
2430 None,
2431 )
2432 .with_context(|| {
2433 format!(
2434 "failed to run {} (is the GitHub CLI installed?)",
2435 bin.display()
2436 )
2437 })?;
2438 if !output.status.success() {
2439 let stderr = String::from_utf8_lossy(&output.stderr);
2440 bail!("gh pr list failed: {}", stderr.trim());
2441 }
2442 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2443 Value::Array(arr) => Ok(arr),
2444 _ => bail!("gh pr list did not return a JSON array"),
2445 }
2446}
2447
2448fn git_status(folder: &Path) -> GitStatus {
2454 git_status_impl(folder, true)
2455}
2456
2457fn git_status_cheap(folder: &Path) -> GitStatus {
2465 git_status_impl(folder, false)
2466}
2467
2468fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2474 let Ok(repo) = Repository::discover(folder) else {
2475 return GitStatus::default();
2476 };
2477 let base = GitStatus {
2483 main_repo: main_repo_name(repo.commondir()),
2484 is_worktree: repo.is_worktree(),
2485 operation: operation_slug(repo.state()),
2486 ..GitStatus::default()
2487 };
2488 let Ok(head) = repo.head() else {
2489 return base;
2491 };
2492 let base = GitStatus {
2498 head_sha: head.target().map(|oid| oid.to_string()),
2499 ..base
2500 };
2501 let Some(name) = head
2505 .shorthand()
2506 .ok()
2507 .filter(|_| head.is_branch())
2508 .map(str::to_string)
2509 else {
2510 return base;
2511 };
2512 let branch = git2::Branch::wrap(head);
2517 let upstream_sha = upstream_target(&branch);
2520 let (ahead, behind) = if with_ahead_behind {
2523 match upstream_ahead_behind(&repo, &branch) {
2524 Some((ahead, behind)) => (Some(ahead), Some(behind)),
2525 None => (None, None),
2526 }
2527 } else {
2528 (None, None)
2529 };
2530 GitStatus {
2531 branch: Some(name),
2532 upstream_sha,
2533 ahead,
2534 behind,
2535 ..base
2536 }
2537}
2538
2539fn operation_slug(state: RepositoryState) -> Option<String> {
2550 let slug = match state {
2551 RepositoryState::Clean => return None,
2552 RepositoryState::Merge => "merge",
2553 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2554 RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2555 RepositoryState::Bisect => "bisect",
2556 RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2557 RepositoryState::RebaseInteractive => "rebase-interactive",
2558 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2559 };
2560 Some(slug.to_string())
2561}
2562
2563fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2573 Some(branch.upstream().ok()?.get().target()?.to_string())
2574}
2575
2576fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2583 let repo = Repository::discover(folder).ok()?;
2584 let head = repo.head().ok()?;
2585 if !head.is_branch() {
2586 return None;
2587 }
2588 let branch = git2::Branch::wrap(head);
2589 upstream_ahead_behind(&repo, &branch)
2590}
2591
2592fn folder_main_behind(folder: &Path) -> Option<usize> {
2607 let repo = Repository::discover(folder).ok()?;
2608 let head = repo.head().ok()?;
2609 if !head.is_branch() {
2610 return None;
2611 }
2612 let branch = git2::Branch::wrap(head);
2613
2614 let remote = "origin";
2615 let default_branch = RemoteInfo::detect_main_branch_local(&repo, remote)?;
2616 let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2617
2618 if let Ok(upstream) = branch.upstream() {
2623 if upstream.get().name() == Ok(onto_ref.as_str()) {
2624 return None;
2625 }
2626 }
2627
2628 let head_oid = branch.get().target()?;
2629 let onto_oid = repo
2630 .revparse_single(&onto_ref)
2631 .ok()?
2632 .peel_to_commit()
2633 .ok()?
2634 .id();
2635 let (_ahead, behind) = repo.graph_ahead_behind(head_oid, onto_oid).ok()?;
2636 Some(behind)
2637}
2638
2639fn main_repo_name(commondir: &Path) -> Option<String> {
2645 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2646 if file_name == ".git" {
2647 commondir
2649 .parent()
2650 .and_then(Path::file_name)
2651 .map(|n| n.to_string_lossy().into_owned())
2652 } else {
2653 Some(
2655 file_name
2656 .strip_suffix(".git")
2657 .unwrap_or(&file_name)
2658 .to_string(),
2659 )
2660 }
2661}
2662
2663fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2666 let upstream = branch.upstream().ok()?;
2667 let local_oid = branch.get().target()?;
2668 let upstream_oid = upstream.get().target()?;
2669 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2670}
2671
2672#[derive(Serialize)]
2678struct EnrichedEntry<'a> {
2679 #[serde(flatten)]
2680 entry: &'a WindowEntry,
2681 #[serde(flatten)]
2682 git: GitStatus,
2683}
2684
2685fn enriched_entry(entry: &WindowEntry) -> Value {
2690 let git = entry
2691 .folders
2692 .first()
2693 .map(|folder| git_status(folder))
2694 .unwrap_or_default();
2695 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2696}
2697
2698async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2702 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2703 .await
2704 .unwrap_or_default()
2705}
2706
2707#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2713struct GithubIdentity {
2714 owner: String,
2716 name: String,
2718}
2719
2720#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2736struct TreeWorktree {
2737 path: String,
2739 #[serde(skip_serializing_if = "Option::is_none")]
2741 branch: Option<String>,
2742 #[serde(skip_serializing_if = "Option::is_none")]
2747 head_sha: Option<String>,
2748 #[serde(skip_serializing_if = "Option::is_none")]
2754 upstream_sha: Option<String>,
2755 is_main: bool,
2757 open: bool,
2759 #[serde(skip_serializing_if = "Option::is_none")]
2762 window_key: Option<String>,
2763 #[serde(skip_serializing_if = "Option::is_none")]
2769 pr: Option<PrBadge>,
2770 #[serde(skip_serializing_if = "is_false")]
2780 pr_none: bool,
2781 #[serde(skip_serializing_if = "Option::is_none")]
2786 operation: Option<String>,
2787 #[serde(skip_serializing_if = "is_false")]
2796 rebasing: bool,
2797 #[serde(skip_serializing_if = "is_false")]
2806 pushing: bool,
2807}
2808
2809#[derive(Debug, Clone, Default)]
2817struct InFlight {
2818 rebasing: HashSet<PathBuf>,
2820 pushing: HashSet<PathBuf>,
2822}
2823
2824impl InFlight {
2825 fn read(registry: &WorktreesRegistry) -> Self {
2829 Self {
2830 rebasing: registry.rebasing_paths(),
2831 pushing: registry.pushing_paths(),
2832 }
2833 }
2834}
2835
2836#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2840struct TreeRepo {
2841 main_repo: String,
2843 #[serde(skip_serializing_if = "Option::is_none")]
2845 github: Option<GithubIdentity>,
2846 root: String,
2848 #[serde(skip_serializing_if = "is_false")]
2856 polling_enabled: bool,
2857 worktrees: Vec<TreeWorktree>,
2860}
2861
2862fn github_identity(url: &str) -> Option<GithubIdentity> {
2869 let url = url.trim();
2870 let rest = [
2872 "https://github.com/",
2873 "http://github.com/",
2874 "ssh://git@github.com/",
2875 "git://github.com/",
2876 "git@github.com:",
2877 ]
2878 .iter()
2879 .find_map(|prefix| url.strip_prefix(prefix))?;
2880 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2881 let rest = rest.trim_end_matches('/');
2882 let mut parts = rest.splitn(2, '/');
2883 let owner = parts.next()?.trim();
2884 let name = parts.next()?.trim();
2885 if owner.is_empty() || name.is_empty() || name.contains('/') {
2887 return None;
2888 }
2889 Some(GithubIdentity {
2890 owner: owner.to_string(),
2891 name: name.to_string(),
2892 })
2893}
2894
2895fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2898 if let Ok(origin) = repo.find_remote("origin") {
2899 if let Some(id) = origin.url().ok().and_then(github_identity) {
2900 return Some(id);
2901 }
2902 }
2903 let names = repo.remotes().ok();
2907 names
2908 .iter()
2909 .flat_map(|arr| arr.iter())
2910 .flatten()
2911 .flatten()
2912 .filter_map(|name| repo.find_remote(name).ok())
2913 .find_map(|remote| remote.url().ok().and_then(github_identity))
2914}
2915
2916fn canonical(path: &Path) -> PathBuf {
2920 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2921}
2922
2923fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2928 let mut index = HashMap::new();
2929 for entry in entries {
2930 for folder in &entry.folders {
2931 index
2932 .entry(canonical(folder))
2933 .or_insert_with(|| entry.key.clone());
2934 }
2935 }
2936 index
2937}
2938
2939fn worktree_entry(
2944 path: &Path,
2945 is_main: bool,
2946 open_index: &HashMap<PathBuf, String>,
2947 in_flight: &InFlight,
2948) -> TreeWorktree {
2949 let status = git_status_cheap(path);
2950 let canonical = canonical(path);
2951 let window_key = open_index.get(&canonical).cloned();
2952 TreeWorktree {
2953 path: path.display().to_string(),
2954 branch: status.branch,
2955 head_sha: status.head_sha,
2956 upstream_sha: status.upstream_sha,
2957 is_main,
2958 open: window_key.is_some(),
2959 window_key,
2960 pr: None,
2963 pr_none: false,
2964 operation: status.operation,
2965 rebasing: in_flight.rebasing.contains(&canonical),
2968 pushing: in_flight.pushing.contains(&canonical),
2969 }
2970}
2971
2972fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3001 for repo in repos {
3002 if let Some(github) = &repo.github {
3003 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3004 }
3005 }
3006}
3007
3008fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3009 for repo in repos {
3010 if !repo.polling_enabled {
3014 continue;
3015 }
3016 let Some(github) = repo.github.clone() else {
3017 continue;
3018 };
3019 for worktree in &mut repo.worktrees {
3020 let Some(branch) = &worktree.branch else {
3021 continue;
3022 };
3023 match pr_cache.get(&github.owner, &github.name, branch) {
3024 Some(PrResolution::Pr(mut badge)) => {
3025 if badge.is_stale_for(worktree.head_sha.as_deref()) {
3026 badge.checks = PrCheckState::Pending;
3027 }
3028 worktree.pr = Some(badge);
3029 }
3030 Some(PrResolution::NoPr) => worktree.pr_none = true,
3031 None => {}
3032 }
3033 }
3034 }
3035}
3036
3037fn repo_tree(
3043 discovered: &Repository,
3044 open_index: &HashMap<PathBuf, String>,
3045 in_flight: &InFlight,
3046) -> Option<TreeRepo> {
3047 let commondir = canonical(discovered.commondir());
3050 let main_root = commondir.parent()?.to_path_buf();
3051 let main_repo = Repository::open(&main_root).ok()?;
3052
3053 let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3055 let names = main_repo.worktrees().ok();
3060 let mut linked: Vec<PathBuf> = names
3061 .iter()
3062 .flat_map(|arr| arr.iter())
3063 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
3066 .map(|wt| wt.path().to_path_buf())
3067 .collect();
3068 linked.sort();
3069 worktrees.extend(
3070 linked
3071 .iter()
3072 .map(|path| worktree_entry(path, false, open_index, in_flight)),
3073 );
3074
3075 Some(TreeRepo {
3076 main_repo: main_repo_name(&commondir)?,
3077 github: remote_github_identity(&main_repo),
3078 root: main_root.display().to_string(),
3079 polling_enabled: false,
3082 worktrees,
3083 })
3084}
3085
3086fn build_tree(
3092 folders: Vec<PathBuf>,
3093 windows: Vec<WindowEntry>,
3094 in_flight: InFlight,
3095) -> Vec<TreeRepo> {
3096 let open_index = open_window_index(&windows);
3097 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3098 for folder in &folders {
3099 let Ok(repo) = Repository::discover(folder) else {
3100 continue;
3101 };
3102 let key = canonical(repo.commondir());
3103 if repos.contains_key(&key) {
3104 continue;
3105 }
3106 if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3107 repos.insert(key, tree);
3108 }
3109 }
3110 repos.into_values().collect()
3111}
3112
3113async fn tree_repos(
3118 folders: Vec<PathBuf>,
3119 windows: Vec<WindowEntry>,
3120 pr_cache: Arc<PrStatusCache>,
3121 enabled_polling: HashSet<String>,
3122 in_flight: InFlight,
3123) -> Vec<Value> {
3124 tokio::task::spawn_blocking(move || {
3125 let mut repos = build_tree(folders, windows, in_flight);
3126 stamp_polling(&mut repos, &enabled_polling);
3129 fold_pr_badges(&mut repos, &pr_cache);
3130 repos
3131 .iter()
3132 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3133 .collect()
3134 })
3135 .await
3136 .unwrap_or_default()
3137}
3138
3139#[derive(Serialize)]
3148struct AheadBehindEntry {
3149 #[serde(skip_serializing_if = "Option::is_none")]
3150 ahead: Option<usize>,
3151 #[serde(skip_serializing_if = "Option::is_none")]
3152 behind: Option<usize>,
3153 #[serde(skip_serializing_if = "Option::is_none")]
3154 main_behind: Option<usize>,
3155}
3156
3157async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
3173 tokio::task::spawn_blocking(move || {
3174 let mut results = serde_json::Map::new();
3175 for path in paths {
3176 let (ahead, behind) =
3177 folder_ahead_behind(&path).map_or((None, None), |(a, b)| (Some(a), Some(b)));
3178 let main_behind = folder_main_behind(&path);
3179 if ahead.is_none() && main_behind.is_none() {
3180 continue;
3181 }
3182 results.insert(
3183 path.display().to_string(),
3184 json!(AheadBehindEntry {
3185 ahead,
3186 behind,
3187 main_behind,
3188 }),
3189 );
3190 }
3191 Value::Object(results)
3192 })
3193 .await
3194 .unwrap_or_else(|_| json!({}))
3195}
3196
3197struct WorktreesStream {
3211 cache: Arc<TreeSnapshotCache>,
3214 changes: watch::Receiver<u64>,
3218}
3219
3220#[async_trait]
3221impl ServiceStream for WorktreesStream {
3222 async fn changed(&mut self) {
3223 if self.changes.changed().await.is_err() {
3229 std::future::pending::<()>().await;
3230 }
3231 }
3232
3233 async fn snapshot(&self) -> Value {
3234 self.cache.snapshot().await
3239 }
3240}
3241
3242struct TreeSnapshotCache {
3266 registry: Arc<WorktreesRegistry>,
3269 pr_cache: Arc<PrStatusCache>,
3272 ttl: Duration,
3276 state: AsyncMutex<Option<CachedTree>>,
3281 computes: AtomicU64,
3285}
3286
3287struct CachedTree {
3290 generation: u64,
3294 computed_at: Instant,
3296 value: Arc<Value>,
3299}
3300
3301impl TreeSnapshotCache {
3302 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3306 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3307 }
3308
3309 fn with_ttl(
3312 registry: Arc<WorktreesRegistry>,
3313 pr_cache: Arc<PrStatusCache>,
3314 ttl: Duration,
3315 ) -> Self {
3316 Self {
3317 registry,
3318 pr_cache,
3319 ttl,
3320 state: AsyncMutex::new(None),
3321 computes: AtomicU64::new(0),
3322 }
3323 }
3324
3325 async fn snapshot(&self) -> Value {
3329 let mut state = self.state.lock().await;
3334 let generation = self.registry.change_generation();
3335 let fresh = state.as_ref().and_then(|cached| {
3338 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3339 .then(|| Arc::clone(&cached.value))
3340 });
3341 let value = if let Some(value) = fresh {
3342 value
3343 } else {
3344 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3345 self.computes.fetch_add(1, Ordering::Relaxed);
3346 *state = Some(CachedTree {
3347 generation,
3348 computed_at: Instant::now(),
3349 value: Arc::clone(&value),
3350 });
3351 value
3352 };
3353 drop(state);
3355 (*value).clone()
3356 }
3357
3358 #[cfg(test)]
3361 fn compute_count(&self) -> u64 {
3362 self.computes.load(Ordering::Relaxed)
3363 }
3364}
3365
3366async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3372 let folders = registry.open_folders();
3373 let windows = registry.list();
3374 let show_closed = registry.show_closed();
3375 let enabled_polling = registry.enabled_polling_repos();
3376 let in_flight = InFlight::read(registry);
3379 json!({
3380 "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3381 "show_closed": show_closed,
3382 })
3383}
3384
3385fn display_name(entry: &WindowEntry) -> String {
3388 if let Some(repo) = &entry.repo {
3389 return repo.clone();
3390 }
3391 if let Some(folder) = entry.folders.first() {
3392 return folder.file_name().map_or_else(
3393 || folder.display().to_string(),
3394 |n| n.to_string_lossy().into_owned(),
3395 );
3396 }
3397 "(no folder)".to_string()
3398}
3399
3400const REPO_SEP: char = '·';
3402const WORKTREE_SEP: char = '⑂';
3405
3406fn menu_items_for(
3411 entries: &[WindowEntry],
3412 rate_limit: Option<&RateLimitSnapshot>,
3413) -> Vec<MenuItem> {
3414 let mut items = Vec::new();
3415 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3419 if !label.is_empty() {
3420 items.push(MenuItem::Label(label));
3421 items.push(MenuItem::Separator);
3422 }
3423 }
3424 if entries.is_empty() {
3425 items.push(MenuItem::Label("No open windows".to_string()));
3426 } else {
3427 items.extend(window_menu_items(entries));
3428 }
3429 items
3430}
3431
3432fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3439 entries
3440 .iter()
3441 .map(|entry| {
3442 let label = window_label(entry);
3443 if entry.folders.is_empty() {
3444 MenuItem::Label(label)
3445 } else {
3446 MenuItem::Action(MenuAction {
3447 id: format!("focus:{}", entry.key),
3448 label,
3449 enabled: true,
3450 })
3451 }
3452 })
3453 .collect()
3454}
3455
3456fn window_label(entry: &WindowEntry) -> String {
3462 let status = entry
3463 .folders
3464 .first()
3465 .map(|folder| git_status(folder))
3466 .unwrap_or_default();
3467 let name = status
3470 .main_repo
3471 .clone()
3472 .unwrap_or_else(|| display_name(entry));
3473 if let Some(branch) = &status.branch {
3474 let sep = if status.is_worktree {
3475 WORKTREE_SEP
3476 } else {
3477 REPO_SEP
3478 };
3479 return match sync_indicator(status.ahead, status.behind) {
3480 Some(sync) => format!("{name} {sep} {branch} {sync}"),
3481 None => format!("{name} {sep} {branch}"),
3482 };
3483 }
3484 match &entry.title {
3486 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3487 _ => name,
3488 }
3489}
3490
3491fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3494 match (ahead, behind) {
3495 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3496 _ => None,
3497 }
3498}
3499
3500const CODE_BINARY_CANDIDATES: &[&str] = &[
3503 "/usr/local/bin/code",
3504 "/opt/homebrew/bin/code",
3505 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3506 "/usr/bin/code",
3507];
3508
3509pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3514 focus_window_with(&resolve_code_binary(), folder)
3515}
3516
3517fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3524 if !folder.is_absolute() {
3529 bail!(
3530 "refusing to focus a non-absolute folder path: {}",
3531 folder.display()
3532 );
3533 }
3534 if !folder.is_dir() {
3535 bail!("worktree folder no longer exists: {}", folder.display());
3536 }
3537 let child = Command::new(program)
3540 .arg(folder)
3541 .stdin(Stdio::null())
3542 .stdout(Stdio::null())
3543 .stderr(Stdio::null())
3544 .spawn()
3545 .with_context(|| {
3546 format!(
3547 "failed to launch `{}` to focus {}",
3548 program.display(),
3549 folder.display()
3550 )
3551 })?;
3552 std::thread::spawn(move || {
3554 let mut child = child;
3555 let _ = child.wait();
3556 });
3557 Ok(())
3558}
3559
3560fn resolve_code_binary() -> PathBuf {
3565 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3566}
3567
3568fn resolve_code_binary_from(
3571 env_override: Option<std::ffi::OsString>,
3572 candidates: &[&str],
3573) -> PathBuf {
3574 if let Some(path) = env_override {
3575 return PathBuf::from(path);
3576 }
3577 for candidate in candidates {
3578 let path = Path::new(candidate);
3579 if path.exists() {
3580 return path.to_path_buf();
3581 }
3582 }
3583 PathBuf::from("code")
3584}
3585
3586#[derive(Debug, Clone, Deserialize)]
3596struct RepositionRequest {
3597 reference_key: String,
3599 #[serde(default)]
3602 target_keys: Vec<String>,
3603 #[serde(default)]
3606 check: bool,
3607}
3608
3609fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3616 entries.iter().find(|entry| entry.key == key).map_or_else(
3617 || geometry::RegisteredWindow {
3618 key: key.to_string(),
3619 live: false,
3620 title: None,
3621 pid: None,
3622 },
3623 |entry| geometry::RegisteredWindow {
3624 key: entry.key.clone(),
3625 live: true,
3626 title: entry.title.clone(),
3627 pid: entry.pid,
3628 },
3629 )
3630}
3631
3632fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3638 let mut reply = json!({
3639 "trusted": report.trusted,
3640 "results": report.results,
3641 "moved": report.moved(),
3642 "skipped": report.skipped(),
3643 });
3644 if let Some(reference) = &report.reference {
3645 reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3646 }
3647 if let Some(blocked) = &report.blocked {
3648 reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3649 }
3650 if undoable {
3653 reply["undoable"] = Value::Bool(true);
3654 }
3655 reply
3656}
3657
3658fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3662 let phase = if !report.trusted {
3665 "untrusted"
3666 } else if report.blocked.is_some() {
3667 "blocked"
3668 } else if req.check {
3669 "check"
3670 } else {
3671 "apply"
3672 };
3673 tracing::info!(
3674 phase,
3675 reference = req.reference_key.as_str(),
3676 requested = req.target_keys.len(),
3677 blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3678 moved = report.moved(),
3679 skipped = report.skipped(),
3680 outcomes = outcome_kinds(report).as_str(),
3681 "reposition"
3682 );
3683}
3684
3685fn log_reposition_undo(report: &geometry::RepositionReport) {
3687 tracing::info!(
3688 trusted = report.trusted,
3689 restored = report.moved(),
3690 skipped = report.skipped(),
3691 outcomes = outcome_kinds(report).as_str(),
3692 "reposition undo"
3693 );
3694}
3695
3696fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3700 if report.results.is_empty() {
3701 return "-".to_string();
3702 }
3703 report
3704 .results
3705 .iter()
3706 .map(|r| r.outcome)
3707 .collect::<Vec<_>>()
3708 .join(",")
3709}
3710
3711#[derive(Debug, Clone, Deserialize)]
3721struct ReloadRequest {
3722 #[serde(default)]
3726 target_keys: Vec<String>,
3727}
3728
3729fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3733 let unknown = if unknown.is_empty() {
3737 "-".to_string()
3738 } else {
3739 unknown.join(",")
3740 };
3741 tracing::info!(
3742 requested,
3743 signalled,
3744 unknown = %unknown,
3745 "worktrees reload: signalled windows"
3746 );
3747}
3748
3749#[derive(Debug, Clone, Deserialize)]
3755struct CloseRequest {
3756 path: PathBuf,
3758 #[serde(default)]
3762 requester_key: Option<String>,
3763 #[serde(default)]
3767 remove: bool,
3768 #[serde(default)]
3771 confirmed: bool,
3772}
3773
3774#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3779struct Note {
3780 kind: String,
3782 detail: String,
3784}
3785
3786impl Note {
3787 fn new(kind: &str, detail: impl Into<String>) -> Self {
3788 Self {
3789 kind: kind.to_string(),
3790 detail: detail.into(),
3791 }
3792 }
3793}
3794
3795fn note_kinds(notes: &[Note]) -> String {
3799 if notes.is_empty() {
3800 return "-".to_string();
3801 }
3802 notes
3803 .iter()
3804 .map(|n| n.kind.as_str())
3805 .collect::<Vec<_>>()
3806 .join(",")
3807}
3808
3809fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3814 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3815}
3816
3817fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3823 tracing::error!(
3824 path = %path.display(),
3825 "worktrees close: {phase} failed: {err:#}"
3826 );
3827 err
3828}
3829
3830fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3841 match removed {
3842 Ok(Removal::Pruned) => {
3843 tracing::info!(
3844 path = %path.display(),
3845 outcome = "pruned",
3846 "worktrees close: linked worktree pruned"
3847 );
3848 Ok(json!({ "removed": true }))
3849 }
3850 Ok(Removal::AlreadyGone) => {
3851 tracing::info!(
3852 path = %path.display(),
3853 outcome = "already-gone",
3854 "worktrees close: nothing to prune, worktree already removed"
3855 );
3856 Ok(json!({ "removed": true }))
3857 }
3858 Err(err) => {
3859 tracing::warn!(
3860 path = %path.display(),
3861 outcome = "failed",
3862 "worktrees close: worktree prune failed: {err:#}"
3863 );
3864 Err(err)
3865 }
3866 }
3867}
3868
3869fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3875 tracing::info!(
3876 path = %path.display(),
3877 window_key = window_key.unwrap_or("-"),
3878 removable = git.removable,
3879 is_main = git.is_main,
3880 open,
3881 risks = %note_kinds(&git.risks),
3882 "worktrees close: safety check"
3883 );
3884}
3885
3886fn log_executing(
3891 path: &Path,
3892 requester: Option<&str>,
3893 remove: bool,
3894 self_close: bool,
3895 cross_window: usize,
3896) {
3897 tracing::info!(
3898 path = %path.display(),
3899 requester = requester.unwrap_or("-"),
3900 remove,
3901 self_close,
3902 cross_window,
3903 "worktrees close: executing"
3904 );
3905}
3906
3907fn log_close_abort(path: &Path, err: &anyhow::Error) {
3911 tracing::warn!(
3912 path = %path.display(),
3913 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3914 );
3915}
3916
3917fn log_window_closed(path: &Path) {
3921 tracing::info!(
3922 path = %path.display(),
3923 "worktrees close: window closed, no removal"
3924 );
3925}
3926
3927#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3931struct SafetyReport {
3932 removable: bool,
3935 is_main: bool,
3937 open: bool,
3939 #[serde(skip_serializing_if = "Option::is_none")]
3941 window_key: Option<String>,
3942 window_folder_count: usize,
3945 risks: Vec<Note>,
3948 info: Vec<Note>,
3951}
3952
3953#[derive(Debug, Clone, PartialEq, Eq)]
3956struct GitSafety {
3957 is_main: bool,
3958 removable: bool,
3959 risks: Vec<Note>,
3960 info: Vec<Note>,
3961}
3962
3963#[derive(Debug, Clone, Deserialize)]
3971struct RebaseRequest {
3972 paths: Vec<PathBuf>,
3974 #[serde(default)]
3977 requester_key: Option<String>,
3978 #[serde(default)]
3980 check: bool,
3981 #[serde(default)]
3983 confirmed: bool,
3984 #[serde(default)]
3989 keep_conflicts: bool,
3990 #[serde(default)]
3993 autostash: bool,
3994 #[serde(default)]
3996 onto: Option<String>,
3997}
3998
3999impl RebaseRequest {
4000 fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4002 worktree_rebase::RebaseOptions {
4003 onto: self.onto.clone(),
4004 autostash: self.autostash,
4005 dry_run: false,
4008 keep_conflicts: self.keep_conflicts,
4009 git_bin: Some(git_bin),
4010 }
4011 }
4012}
4013
4014async fn plan_rebase(
4018 selection: &Selection,
4019 opts: &worktree_rebase::RebaseOptions,
4020) -> Result<worktree_rebase::Plan> {
4021 let selection = selection.clone();
4022 let opts = opts.clone();
4023 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4024 .await
4025 .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4026 .and_then(|inner| inner)
4027}
4028
4029fn rebase_reply(
4034 fetches: &[worktree_rebase::FetchOutcome],
4035 worktrees: &[worktree_rebase::WorktreeOutcome],
4036) -> Value {
4037 json!({ "fetches": fetches, "worktrees": worktrees })
4038}
4039
4040fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4044 let pending = plan
4045 .worktrees
4046 .iter()
4047 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4048 .count();
4049 let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4050 tracing::info!(
4051 requester = req.requester_key.as_deref().unwrap_or("-"),
4052 requested = req.paths.len(),
4053 pending,
4054 fetches = plan.fetches.len(),
4055 failed_fetches,
4056 "rebase check"
4057 );
4058}
4059
4060fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4064 use worktree_rebase::RebaseResult;
4065 let mut rebased = 0;
4066 let mut conflicts = 0;
4067 let mut left_in_place = 0;
4068 let mut skipped = 0;
4069 for outcome in outcomes {
4070 match &outcome.result {
4071 RebaseResult::Rebased { .. } => rebased += 1,
4072 RebaseResult::Conflict {
4073 left_in_place: k, ..
4074 } => {
4075 conflicts += 1;
4076 if *k {
4077 left_in_place += 1;
4078 }
4079 }
4080 RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4081 RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4082 }
4083 }
4084 tracing::info!(
4085 requester = req.requester_key.as_deref().unwrap_or("-"),
4086 requested = req.paths.len(),
4087 rebased,
4088 conflicts,
4089 left_in_place,
4090 skipped,
4091 "rebase execute"
4092 );
4093}
4094
4095#[derive(Debug, Clone, Deserialize)]
4107struct PushRequest {
4108 paths: Vec<PathBuf>,
4110 #[serde(default)]
4113 requester_key: Option<String>,
4114 #[serde(default)]
4116 check: bool,
4117 #[serde(default)]
4119 confirmed: bool,
4120}
4121
4122async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4126 let selection = selection.clone();
4127 tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4128 .await
4129 .map_err(|e| anyhow!("push planning task panicked: {e}"))
4130 .and_then(|inner| inner)
4131}
4132
4133fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4141 json!({ "worktrees": worktrees })
4142}
4143
4144fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4148 use worktree_push::PushResult;
4149 let pending = plan
4150 .worktrees
4151 .iter()
4152 .filter(|w| w.result.is_pending())
4153 .count();
4154 let forced = plan
4155 .worktrees
4156 .iter()
4157 .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4158 .count();
4159 let skipped = plan
4160 .worktrees
4161 .iter()
4162 .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4163 .count();
4164 tracing::info!(
4165 requester = req.requester_key.as_deref().unwrap_or("-"),
4166 requested = req.paths.len(),
4167 pending,
4168 forced,
4169 skipped,
4170 "push check"
4171 );
4172}
4173
4174fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4179 use worktree_push::PushResult;
4180 let mut pushed = 0;
4181 let mut forced = 0;
4182 let mut created = 0;
4183 let mut rejected = 0;
4184 let mut stale_rejected = 0;
4185 for outcome in outcomes {
4186 match &outcome.result {
4187 PushResult::Pushed { forced: f } => {
4188 pushed += 1;
4189 if *f {
4190 forced += 1;
4191 }
4192 }
4193 PushResult::Created => created += 1,
4194 PushResult::Rejected { stale, .. } => {
4195 rejected += 1;
4196 if *stale {
4197 stale_rejected += 1;
4198 }
4199 }
4200 PushResult::UpToDate
4201 | PushResult::WouldFastForward { .. }
4202 | PushResult::WouldForce { .. }
4203 | PushResult::WouldCreate
4204 | PushResult::Skipped { .. } => {}
4205 }
4206 }
4207 tracing::info!(
4208 requester = req.requester_key.as_deref().unwrap_or("-"),
4209 requested = req.paths.len(),
4210 pushed,
4211 forced,
4212 created,
4213 rejected,
4214 stale_rejected,
4215 "push execute"
4216 );
4217}
4218
4219#[derive(Debug, Clone, Deserialize)]
4225struct MergeQueueRequest {
4226 paths: Vec<PathBuf>,
4228 #[serde(default)]
4231 requester_key: Option<String>,
4232 #[serde(default)]
4234 check: bool,
4235 #[serde(default)]
4237 confirmed: bool,
4238}
4239
4240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4242struct PrRef {
4243 path: String,
4245 number: u64,
4247 url: String,
4249 branch: String,
4251}
4252
4253impl From<&Eligible> for PrRef {
4254 fn from(e: &Eligible) -> Self {
4255 Self {
4256 path: e.path.to_string_lossy().to_string(),
4257 number: e.number,
4258 url: e.url.clone(),
4259 branch: e.branch.clone(),
4260 }
4261 }
4262}
4263
4264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4267struct Skip {
4268 path: String,
4269 kind: String,
4270 detail: String,
4271}
4272
4273impl Skip {
4274 fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4275 Self {
4276 path: path.to_string_lossy().to_string(),
4277 kind: kind.to_string(),
4278 detail: detail.into(),
4279 }
4280 }
4281}
4282
4283#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4287struct EligibilityReport {
4288 eligible: Vec<PrRef>,
4289 skipped: Vec<Skip>,
4290}
4291
4292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4294struct QueuedPr {
4295 path: String,
4296 number: u64,
4297 #[serde(skip_serializing_if = "is_false")]
4300 already_queued: bool,
4301}
4302
4303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4306struct EnqueueFailure {
4307 path: String,
4308 number: u64,
4309 error: String,
4310}
4311
4312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4315struct EnqueueResult {
4316 queued: Vec<QueuedPr>,
4317 skipped: Vec<Skip>,
4318 failed: Vec<EnqueueFailure>,
4319}
4320
4321#[derive(Debug)]
4324struct LocalOk {
4325 path: PathBuf,
4326 target: PrTarget,
4327 head_sha: String,
4328}
4329
4330#[derive(Debug)]
4332struct Eligible {
4333 path: PathBuf,
4334 number: u64,
4335 url: String,
4336 branch: String,
4337 pr_id: String,
4339 already_queued: bool,
4341}
4342
4343fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4349 let Ok(repo) = Repository::discover(path) else {
4350 return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4351 };
4352 let (dirty, untracked) = count_dirty_untracked(&repo);
4354 if dirty > 0 {
4355 return Err(Skip::new(
4356 path,
4357 "dirty",
4358 format!("{dirty} modified tracked file(s) — commit or stash first"),
4359 ));
4360 }
4361 if untracked > 0 {
4362 return Err(Skip::new(
4363 path,
4364 "untracked",
4365 format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4366 ));
4367 }
4368 let Ok(head) = repo.head() else {
4371 return Err(Skip::new(
4372 path,
4373 "no-commits",
4374 "the branch has no commits yet",
4375 ));
4376 };
4377 let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4378 return Err(Skip::new(
4379 path,
4380 "no-commits",
4381 "HEAD does not resolve to a commit",
4382 ));
4383 };
4384 let Some(branch_name) = head
4387 .shorthand()
4388 .ok()
4389 .filter(|_| head.is_branch())
4390 .map(str::to_string)
4391 else {
4392 return Err(Skip::new(
4393 path,
4394 "detached",
4395 "HEAD is detached — no branch to enqueue",
4396 ));
4397 };
4398 let branch = git2::Branch::wrap(head);
4399 let Some(upstream_sha) = upstream_target(&branch) else {
4401 return Err(Skip::new(
4402 path,
4403 "no-upstream",
4404 "the branch tracks no upstream — push it first",
4405 ));
4406 };
4407 if upstream_sha != head_sha {
4408 return Err(Skip::new(
4409 path,
4410 "unpushed",
4411 "local commits are not on the remote yet — push first",
4412 ));
4413 }
4414 if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4417 if ahead > 0 {
4418 return Err(Skip::new(
4419 path,
4420 "unpushed",
4421 format!("{ahead} unpushed commit(s) — push first"),
4422 ));
4423 }
4424 }
4425 let Some(id) = remote_github_identity(&repo) else {
4428 return Err(Skip::new(
4429 path,
4430 "no-github",
4431 "the repository has no github.com remote",
4432 ));
4433 };
4434 Ok(LocalOk {
4435 path: path.to_path_buf(),
4436 target: PrTarget {
4437 owner: id.owner,
4438 name: id.name,
4439 branch: branch_name,
4440 },
4441 head_sha,
4442 })
4443}
4444
4445fn is_conflicting(state: Option<&str>) -> bool {
4450 matches!(state, Some("CONFLICTING" | "DIRTY"))
4451}
4452
4453fn check_label(state: PrCheckState) -> &'static str {
4455 match state {
4456 PrCheckState::Success => "passing",
4457 PrCheckState::Failure => "failing",
4458 PrCheckState::Pending => "still running",
4459 PrCheckState::None => "not reported",
4460 }
4461}
4462
4463fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4468 tracing::info!(
4469 requester = req.requester_key.as_deref().unwrap_or("-"),
4470 requested = req.paths.len(),
4471 eligible,
4472 skipped,
4473 "merge-queue check"
4474 );
4475}
4476
4477fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4481 tracing::info!(
4482 requester = req.requester_key.as_deref().unwrap_or("-"),
4483 queued,
4484 failed,
4485 skipped,
4486 "merge-queue enqueue"
4487 );
4488}
4489
4490fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4501 let mut skipped = Vec::new();
4502 let mut locals = Vec::new();
4503 for path in paths {
4504 match evaluate_local(path) {
4505 Ok(ok) => locals.push(ok),
4506 Err(skip) => skipped.push(skip),
4507 }
4508 }
4509 if locals.is_empty() {
4510 return Ok((Vec::new(), skipped));
4511 }
4512 let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4513 let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4514 let mut eligible = Vec::new();
4515 for local in locals {
4516 let Some(info) = resolved.get(&local.target) else {
4517 skipped.push(Skip::new(
4518 &local.path,
4519 "no-pr",
4520 "no open PR heads this branch",
4521 ));
4522 continue;
4523 };
4524 if info.head_oid != local.head_sha {
4525 skipped.push(Skip::new(
4526 &local.path,
4527 "stale",
4528 "the open PR's head differs from the local head — re-check",
4529 ));
4530 } else if info.is_draft {
4531 skipped.push(Skip::new(
4532 &local.path,
4533 "draft",
4534 format!("PR #{} is a draft", info.number),
4535 ));
4536 } else if is_conflicting(info.merge_state.as_deref()) {
4537 skipped.push(Skip::new(
4538 &local.path,
4539 "conflicting",
4540 format!("PR #{} has merge conflicts", info.number),
4541 ));
4542 } else if info.checks != PrCheckState::Success {
4543 skipped.push(Skip::new(
4544 &local.path,
4545 "checks-failing",
4546 format!(
4547 "PR #{} checks are {}",
4548 info.number,
4549 check_label(info.checks)
4550 ),
4551 ));
4552 } else {
4553 eligible.push(Eligible {
4554 path: local.path,
4555 number: info.number,
4556 url: info.url.clone(),
4557 branch: local.target.branch.clone(),
4558 pr_id: info.pr_id.clone(),
4559 already_queued: info.already_queued,
4560 });
4561 }
4562 }
4563 Ok((eligible, skipped))
4564}
4565
4566fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4571 let mut queued = Vec::new();
4572 let mut failed = Vec::new();
4573 for e in eligible {
4574 let path = e.path.to_string_lossy().to_string();
4575 if e.already_queued {
4576 queued.push(QueuedPr {
4577 path,
4578 number: e.number,
4579 already_queued: true,
4580 });
4581 continue;
4582 }
4583 match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4584 Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4585 path,
4586 number: e.number,
4587 already_queued: false,
4588 }),
4589 Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4590 path,
4591 number: e.number,
4592 error: msg,
4593 }),
4594 Err(err) => failed.push(EnqueueFailure {
4595 path,
4596 number: e.number,
4597 error: format!("{err:#}"),
4598 }),
4599 }
4600 }
4601 (queued, failed)
4602}
4603
4604fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4608 let target = canonical(path);
4609 entries
4610 .iter()
4611 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4612 .map(|e| (e.key.clone(), e.folders.len()))
4613 .collect()
4614}
4615
4616const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4623
4624const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4627
4628async fn await_windows_closed(
4635 registry: &WorktreesRegistry,
4636 path: &Path,
4637 requester: Option<&str>,
4638 timeout: Duration,
4639 poll: Duration,
4640) -> Result<()> {
4641 let deadline = std::time::Instant::now() + timeout;
4642 loop {
4643 let entries = registry.list();
4647 let path = path.to_path_buf();
4648 let requester = requester.map(str::to_string);
4649 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4650 windows_with_path(&entries, &path)
4651 .into_iter()
4652 .map(|(k, _)| k)
4653 .filter(|k| requester.as_deref() != Some(k))
4654 .collect()
4655 })
4656 .await
4657 .unwrap_or_default();
4658
4659 if remaining.is_empty() {
4660 return Ok(());
4661 }
4662 if std::time::Instant::now() >= deadline {
4663 bail!("window(s) did not close in time: {}", remaining.join(", "));
4664 }
4665 tokio::time::sleep(poll).await;
4666 }
4667}
4668
4669fn git_safety(path: &Path) -> Result<GitSafety> {
4676 if !path.exists() {
4677 return Ok(GitSafety {
4678 is_main: false,
4679 removable: true,
4680 risks: vec![],
4681 info: vec![Note::new("already-removed", "worktree no longer exists")],
4682 });
4683 }
4684 let repo = Repository::open(path)
4685 .with_context(|| format!("not a git worktree: {}", path.display()))?;
4686 if !repo.is_worktree() {
4688 return Ok(GitSafety {
4689 is_main: true,
4690 removable: false,
4691 risks: vec![],
4692 info: vec![Note::new(
4693 "main-working-tree",
4694 "the repository's main working tree is never deleted",
4695 )],
4696 });
4697 }
4698
4699 let mut risks = Vec::new();
4700 let mut info = Vec::new();
4701
4702 let (dirty, untracked) = count_dirty_untracked(&repo);
4703 if dirty > 0 {
4704 risks.push(Note::new(
4705 "dirty",
4706 format!("{dirty} modified tracked file(s) would be lost"),
4707 ));
4708 }
4709 if untracked > 0 {
4710 risks.push(Note::new(
4711 "untracked",
4712 format!("{untracked} untracked file(s) would be lost"),
4713 ));
4714 }
4715
4716 let state = repo.state();
4718 if state != RepositoryState::Clean {
4719 risks.push(Note::new(
4720 "in-progress",
4721 format!("an in-progress {state:?} operation would be lost"),
4722 ));
4723 }
4724
4725 if repo.head_detached().unwrap_or(false) {
4729 let lost = unreachable_commit_count(&repo).unwrap_or(0);
4730 if lost > 0 {
4731 risks.push(Note::new(
4732 "unreachable-commits",
4733 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4734 ));
4735 }
4736 }
4737
4738 if let Some(ahead) = current_branch_ahead(&repo) {
4741 if ahead > 0 {
4742 info.push(Note::new(
4743 "unpushed",
4744 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4745 ));
4746 }
4747 }
4748
4749 Ok(GitSafety {
4750 is_main: false,
4751 removable: true,
4752 risks,
4753 info,
4754 })
4755}
4756
4757fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4764 let mut opts = StatusOptions::new();
4765 opts.include_untracked(true)
4766 .recurse_untracked_dirs(true)
4767 .include_ignored(false)
4768 .exclude_submodules(true);
4769 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4770 return (0, 0);
4771 };
4772 let tracked = Status::INDEX_NEW
4775 | Status::INDEX_MODIFIED
4776 | Status::INDEX_DELETED
4777 | Status::INDEX_RENAMED
4778 | Status::INDEX_TYPECHANGE
4779 | Status::WT_MODIFIED
4780 | Status::WT_DELETED
4781 | Status::WT_TYPECHANGE
4782 | Status::WT_RENAMED
4783 | Status::CONFLICTED;
4784 let mut dirty = 0;
4785 let mut untracked = 0;
4786 for entry in statuses.iter() {
4787 let s = entry.status();
4788 if s.contains(Status::WT_NEW) {
4789 untracked += 1;
4790 }
4791 if s.intersects(tracked) {
4792 dirty += 1;
4793 }
4794 }
4795 (dirty, untracked)
4796}
4797
4798fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4805 let head_oid = repo.head().ok()?.target()?;
4806 let mut walk = repo.revwalk().ok()?;
4807 walk.push(head_oid).ok()?;
4808 for reference in repo.references().ok()? {
4809 let Ok(reference) = reference else { continue };
4810 if matches!(reference.name(), Ok("HEAD")) {
4813 continue;
4814 }
4815 if let Some(oid) = reference.target() {
4816 let _ = walk.hide(oid);
4817 }
4818 }
4819 Some(walk.flatten().count())
4820}
4821
4822fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4826 let head = repo.head().ok()?;
4827 if !head.is_branch() {
4828 return None;
4829 }
4830 let branch = git2::Branch::wrap(head);
4831 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4832}
4833
4834fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4840 let names = main_repo.worktrees()?;
4841 names
4842 .iter()
4843 .flatten() .flatten() .find(|name| {
4846 main_repo
4847 .find_worktree(name)
4848 .is_ok_and(|wt| canonical(wt.path()) == target)
4849 })
4850 .map(str::to_string)
4851 .ok_or_else(|| {
4852 anyhow!(
4853 "worktree {} is not registered in {}",
4854 target.display(),
4855 main_repo.path().display()
4856 )
4857 })
4858}
4859
4860const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4869 Duration::from_millis(250),
4870 Duration::from_millis(500),
4871 Duration::from_secs(1),
4872 Duration::from_secs(1),
4873];
4874
4875fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4881 matches!(
4882 e.raw_os_error(),
4883 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4884 )
4885}
4886
4887fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4893 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4894}
4895
4896fn remove_dir_all_retrying_with(
4902 dir: &Path,
4903 backoff: &[Duration],
4904 mut remove: impl FnMut() -> std::io::Result<()>,
4905) -> Result<()> {
4906 let mut backoff = backoff.iter();
4907 loop {
4908 match remove() {
4909 Ok(()) => return Ok(()),
4910 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4911 Err(e) => {
4912 if is_transient_rmdir_error(&e) {
4913 if let Some(delay) = backoff.next() {
4914 std::thread::sleep(*delay);
4915 continue;
4916 }
4917 }
4918 return Err(e).with_context(|| {
4919 format!("failed to remove worktree directory {}", dir.display())
4920 });
4921 }
4922 }
4923 }
4924}
4925
4926fn is_orphaned_worktree(path: &Path) -> bool {
4935 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4938 return false;
4939 };
4940 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4941 return false;
4942 };
4943 let admin = Path::new(admin);
4944 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4946}
4947
4948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4953enum Removal {
4954 Pruned,
4956 AlreadyGone,
4958}
4959
4960fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4985 if !path.exists() {
4986 return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4987 }
4988 let repo = match Repository::open(path) {
4989 Ok(repo) => repo,
4990 Err(_) if is_orphaned_worktree(path) => {
4993 remove_dir_all_retrying(path)?;
4994 return Ok(Removal::Pruned);
4995 }
4996 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4997 };
4998 if !repo.is_worktree() {
4999 bail!(
5000 "refusing to delete the main working tree: {}",
5001 path.display()
5002 );
5003 }
5004 let commondir = canonical(repo.commondir());
5007 let main_root = commondir
5008 .parent()
5009 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5010 .to_path_buf();
5011 drop(repo);
5013 let main_repo = Repository::open(&main_root)
5014 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5015 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5016 let worktree = main_repo.find_worktree(&name)?;
5017
5018 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5020 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5021 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5022 }
5023
5024 remove_dir_all_retrying(path)?;
5027
5028 let mut opts = git2::WorktreePruneOptions::new();
5033 opts.valid(true).working_tree(false);
5034 worktree
5035 .prune(Some(&mut opts))
5036 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5037 Ok(Removal::Pruned)
5038}
5039
5040fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5056 let mut roots: Vec<PathBuf> = Vec::new();
5057 let mut push = |root: PathBuf| {
5058 if !roots.contains(&root) {
5059 roots.push(root);
5060 }
5061 };
5062 for ancestor in path.ancestors().skip(1) {
5064 if let Ok(repo) = Repository::open(ancestor) {
5065 if !repo.is_worktree() {
5066 if let Some(root) = canonical(repo.commondir()).parent() {
5067 push(root.to_path_buf());
5068 }
5069 }
5070 }
5071 }
5072 for folder in windows.iter().flat_map(|w| &w.folders) {
5073 if let Ok(repo) = Repository::discover(folder) {
5074 if let Some(root) = canonical(repo.commondir()).parent() {
5075 push(root.to_path_buf());
5076 }
5077 }
5078 }
5079 roots
5080}
5081
5082fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5091 let target = canonical(path);
5092 for root in candidate_main_repos {
5093 let Ok(main_repo) = Repository::open(root) else {
5094 continue;
5095 };
5096 if main_repo.is_worktree() {
5098 continue;
5099 }
5100 let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5102 continue;
5103 };
5104 let worktree = main_repo.find_worktree(&name)?;
5105 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5106 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5107 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5108 }
5109 let mut opts = git2::WorktreePruneOptions::new();
5110 opts.valid(true).working_tree(false);
5111 worktree.prune(Some(&mut opts)).with_context(|| {
5112 format!(
5113 "failed to prune orphaned worktree metadata for {}",
5114 path.display()
5115 )
5116 })?;
5117 return Ok(Removal::Pruned);
5118 }
5119 Ok(Removal::AlreadyGone)
5120}
5121
5122#[cfg(test)]
5123#[allow(clippy::unwrap_used, clippy::expect_used)]
5124mod tests {
5125 use super::*;
5126 use crate::test_support::shim::{
5127 retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5128 };
5129 use std::sync::MutexGuard;
5130
5131 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5132 json!({
5133 "key": key,
5134 "folders": [folder],
5135 "repo": repo,
5136 "title": format!("{key}-title"),
5137 "pid": 1234,
5138 })
5139 }
5140
5141 fn windows_of(payload: &Value) -> &Vec<Value> {
5143 payload
5144 .get("windows")
5145 .and_then(Value::as_array)
5146 .expect("windows array")
5147 }
5148
5149 #[tokio::test]
5150 async fn name_and_unknown_op() {
5151 let svc = WorktreesService::new();
5152 assert_eq!(svc.name(), "worktrees");
5153 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5154 }
5155
5156 #[tokio::test]
5157 async fn handle_routes_ops_and_shapes_payloads() {
5158 let svc = WorktreesService::new();
5159 let payload = svc.handle("list", Value::Null).await.unwrap();
5161 assert_eq!(payload, json!({ "windows": [] }));
5162
5163 let reply = svc
5165 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5166 .await
5167 .unwrap();
5168 assert_eq!(reply, json!({ "ok": true }));
5169 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5170 assert_eq!(windows.len(), 1);
5171 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5172 assert!(windows[0].get("last_seen").is_some());
5173
5174 let known = svc
5176 .handle("heartbeat", json!({ "key": "w1" }))
5177 .await
5178 .unwrap();
5179 assert_eq!(known, json!({ "known": true }));
5180 let unknown = svc
5181 .handle("heartbeat", json!({ "key": "nope" }))
5182 .await
5183 .unwrap();
5184 assert_eq!(unknown, json!({ "known": false }));
5185
5186 let reloaded = svc
5188 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5189 .await
5190 .unwrap();
5191 assert_eq!(
5192 reloaded,
5193 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5194 );
5195 assert!(svc.registry.take_reload_pending("w1"));
5196
5197 let gone = svc
5199 .handle("unregister", json!({ "key": "w1" }))
5200 .await
5201 .unwrap();
5202 assert_eq!(gone, json!({ "removed": true }));
5203 let again = svc
5204 .handle("unregister", json!({ "key": "w1" }))
5205 .await
5206 .unwrap();
5207 assert_eq!(again, json!({ "removed": false }));
5208 }
5209
5210 #[derive(Clone)]
5222 struct StubBackend {
5223 trusted: bool,
5224 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5227 writes: Arc<Mutex<Vec<geometry::Frame>>>,
5229 }
5230
5231 impl StubBackend {
5232 fn new(trusted: bool) -> Self {
5235 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5236 title: title.to_string(),
5237 frame: geometry::Frame {
5238 x,
5239 y: 0.0,
5240 width,
5241 height: 600.0,
5242 },
5243 minimized: false,
5244 fullscreen: false,
5245 standard: true,
5246 focused: false,
5247 };
5248 Self {
5249 trusted,
5250 windows: Arc::new(Mutex::new(vec![
5251 window("plan.md — ref-tree", 0.0, 800.0),
5252 window("main.rs — other-tree", 900.0, 500.0),
5253 ])),
5254 writes: Arc::new(Mutex::new(Vec::new())),
5255 }
5256 }
5257
5258 fn writes(&self) -> Vec<geometry::Frame> {
5259 self.writes
5260 .lock()
5261 .unwrap_or_else(PoisonError::into_inner)
5262 .clone()
5263 }
5264
5265 fn frame_of(&self, index: usize) -> geometry::Frame {
5267 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5268 }
5269
5270 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5272 let clone = self.clone();
5273 move || clone
5274 }
5275 }
5276
5277 impl geometry::WindowBackend for StubBackend {
5278 fn trusted(&self) -> bool {
5279 self.trusted
5280 }
5281
5282 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5283 pids.iter()
5284 .filter(|p| **p == 11 || **p == 12)
5285 .map(|p| (*p, 900))
5286 .collect()
5287 }
5288
5289 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5290 if app_pid != 900 {
5291 return Ok(Vec::new());
5292 }
5293 Ok(self
5294 .windows
5295 .lock()
5296 .unwrap_or_else(PoisonError::into_inner)
5297 .clone())
5298 }
5299
5300 fn set_frame(
5301 &self,
5302 id: geometry::WindowId,
5303 frame: geometry::Frame,
5304 ) -> Result<geometry::Frame, String> {
5305 self.writes
5306 .lock()
5307 .unwrap_or_else(PoisonError::into_inner)
5308 .push(frame);
5309 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5310 let window = windows
5311 .get_mut(id.index)
5312 .ok_or_else(|| format!("no window at index {}", id.index))?;
5313 window.frame = frame;
5314 Ok(frame)
5315 }
5316 }
5317
5318 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5321 svc.registry.register(
5322 serde_json::from_value(json!({
5323 "key": key,
5324 "folders": [format!("/tmp/{key}")],
5325 "title": title,
5326 "pid": pid,
5327 }))
5328 .expect("valid register payload"),
5329 );
5330 }
5331
5332 #[tokio::test]
5333 async fn reposition_requires_a_resolvable_reference() {
5334 let svc = WorktreesService::new();
5335 assert!(svc.handle("reposition", json!({})).await.is_err());
5338 assert!(svc
5339 .handle("reposition", json!({ "reference_key": " " }))
5340 .await
5341 .is_err());
5342 assert!(svc
5343 .handle("reposition", json!({ "reference_key": "ghost" }))
5344 .await
5345 .is_err());
5346 }
5347
5348 #[tokio::test]
5349 async fn reposition_moves_targets_and_records_an_undo() {
5350 let svc = WorktreesService::new();
5351 register_window(&svc, "ref", "ref-tree", 11);
5352 register_window(&svc, "other", "other-tree", 12);
5353 let backend = StubBackend::new(true);
5354
5355 let reply = svc
5356 .reposition_with(
5357 serde_json::from_value(json!({
5358 "reference_key": "ref",
5359 "target_keys": ["other"],
5360 }))
5361 .unwrap(),
5362 backend.factory(),
5363 )
5364 .await
5365 .unwrap();
5366
5367 assert_eq!(reply["trusted"], json!(true));
5368 assert_eq!(reply["moved"], json!(1));
5369 assert_eq!(reply["skipped"], json!(0));
5370 assert_eq!(reply["undoable"], json!(true));
5371 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5372 assert_eq!(reply["results"][0]["key"], json!("other"));
5373 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5374 assert_eq!(backend.writes().len(), 1);
5376 assert_eq!(
5377 backend.writes()[0],
5378 backend.frame_of(0),
5379 "wrote the reference window's own frame"
5380 );
5381 assert_eq!(
5382 backend.frame_of(1),
5383 backend.frame_of(0),
5384 "the target now occupies the reference's frame"
5385 );
5386
5387 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5391 assert_eq!(undone["moved"], json!(1));
5392 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5393 assert!(undone.get("reference").is_none(), "undo has no reference");
5394 assert_eq!(
5395 backend.frame_of(1),
5396 geometry::Frame {
5397 x: 900.0,
5398 y: 0.0,
5399 width: 500.0,
5400 height: 600.0,
5401 },
5402 "restored to exactly the pre-move frame"
5403 );
5404
5405 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5406 assert_eq!(
5407 again,
5408 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5409 );
5410 }
5411
5412 #[tokio::test]
5413 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5414 let svc = WorktreesService::new();
5415 register_window(&svc, "ref", "ref-tree", 11);
5416 register_window(&svc, "other", "other-tree", 12);
5417 let backend = StubBackend::new(true);
5418
5419 let reply = svc
5420 .reposition_with(
5421 serde_json::from_value(json!({
5422 "reference_key": "ref",
5423 "target_keys": ["other"],
5424 "check": true,
5425 }))
5426 .unwrap(),
5427 backend.factory(),
5428 )
5429 .await
5430 .unwrap();
5431
5432 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5433 assert!(
5434 reply.get("undoable").is_none(),
5435 "a dry run leaves nothing to undo"
5436 );
5437 assert!(
5438 backend.writes().is_empty(),
5439 "a dry run must not touch a window"
5440 );
5441 }
5442
5443 #[tokio::test]
5444 async fn reposition_reports_a_missing_permission_as_data() {
5445 let svc = WorktreesService::new();
5446 register_window(&svc, "ref", "ref-tree", 11);
5447 register_window(&svc, "other", "other-tree", 12);
5448 let backend = StubBackend::new(false);
5449
5450 let reply = svc
5451 .reposition_with(
5452 serde_json::from_value(json!({
5453 "reference_key": "ref",
5454 "target_keys": ["other"],
5455 }))
5456 .unwrap(),
5457 backend.factory(),
5458 )
5459 .await
5460 .unwrap();
5461
5462 assert_eq!(reply["trusted"], json!(false));
5465 assert_eq!(reply["moved"], json!(0));
5466 assert!(backend.writes().is_empty());
5467 }
5468
5469 #[tokio::test]
5470 async fn a_stale_target_key_is_skipped_not_fatal() {
5471 let svc = WorktreesService::new();
5472 register_window(&svc, "ref", "ref-tree", 11);
5473 register_window(&svc, "other", "other-tree", 12);
5474 let backend = StubBackend::new(true);
5475
5476 let reply = svc
5477 .reposition_with(
5478 serde_json::from_value(json!({
5479 "reference_key": "ref",
5480 "target_keys": ["closed-since", "ref", "other"],
5483 }))
5484 .unwrap(),
5485 backend.factory(),
5486 )
5487 .await
5488 .unwrap();
5489
5490 let outcomes: Vec<&str> = reply["results"]
5491 .as_array()
5492 .unwrap()
5493 .iter()
5494 .map(|r| r["outcome"].as_str().unwrap())
5495 .collect();
5496 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5497 assert_eq!(reply["moved"], json!(1));
5498 assert_eq!(reply["skipped"], json!(2));
5499 }
5500
5501 #[tokio::test]
5502 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5503 let svc = WorktreesService::new();
5504 register_window(&svc, "ref", "twin", 11);
5507 register_window(&svc, "other", "other-tree", 12);
5508 let backend = StubBackend::new(true);
5511 {
5512 let mut windows = backend
5513 .windows
5514 .lock()
5515 .unwrap_or_else(PoisonError::into_inner);
5516 windows[0].title = "a.rs — twin".to_string();
5517 windows[1].title = "b.rs — twin".to_string();
5518 }
5519
5520 let reply = svc
5521 .reposition_with(
5522 serde_json::from_value(json!({
5523 "reference_key": "ref",
5524 "target_keys": ["other"],
5525 }))
5526 .unwrap(),
5527 backend.factory(),
5528 )
5529 .await
5530 .unwrap();
5531
5532 assert_eq!(reply["trusted"], json!(true));
5533 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5534 assert!(
5535 reply["blocked"]["detail"]
5536 .as_str()
5537 .is_some_and(|d| d.contains("twin")),
5538 "the reason should name the ambiguous title: {reply}"
5539 );
5540 assert_eq!(reply["results"], json!([]), "no target is attempted");
5541 assert!(reply.get("undoable").is_none());
5542 assert!(backend.writes().is_empty());
5543
5544 let undone = svc
5546 .reposition_undo_with(StubBackend::new(true).factory())
5547 .await
5548 .unwrap();
5549 assert_eq!(undone["moved"], json!(0));
5550 }
5551
5552 #[tokio::test]
5553 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5554 let svc = WorktreesService::new();
5555 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5556 assert_eq!(reply["moved"], json!(0));
5557 assert_eq!(reply["results"], json!([]));
5558 }
5559
5560 #[test]
5561 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5562 let empty = geometry::RepositionReport {
5563 trusted: true,
5564 blocked: None,
5565 reference: None,
5566 results: Vec::new(),
5567 undo: Vec::new(),
5568 };
5569 assert_eq!(outcome_kinds(&empty), "-");
5570 }
5571
5572 #[tokio::test]
5573 async fn handle_rejects_missing_or_empty_key() {
5574 let svc = WorktreesService::new();
5575 assert!(svc.handle("register", json!({})).await.is_err());
5577 assert!(svc
5578 .handle("register", json!({ "key": " " }))
5579 .await
5580 .is_err());
5581 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5583 assert!(svc.handle("unregister", json!({})).await.is_err());
5584 }
5585
5586 #[test]
5587 fn display_name_prefers_repo_then_folder_basename() {
5588 let base = WindowEntry {
5589 key: "k".to_string(),
5590 folders: vec![PathBuf::from("/home/me/project")],
5591 repo: Some("my-repo".to_string()),
5592 title: None,
5593 pid: None,
5594 last_seen: Utc::now(),
5595 };
5596 assert_eq!(display_name(&base), "my-repo");
5597
5598 let no_repo = WindowEntry {
5599 repo: None,
5600 ..base.clone()
5601 };
5602 assert_eq!(display_name(&no_repo), "project");
5603
5604 let nothing = WindowEntry {
5605 repo: None,
5606 folders: vec![],
5607 ..base.clone()
5608 };
5609 assert_eq!(display_name(¬hing), "(no folder)");
5610
5611 let rootish = WindowEntry {
5614 repo: None,
5615 folders: vec![PathBuf::from("/")],
5616 ..base
5617 };
5618 assert_eq!(display_name(&rootish), "/");
5619 }
5620
5621 #[test]
5622 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5623 let now = Utc::now();
5624 let entries = vec![
5625 WindowEntry {
5630 key: "k2".to_string(),
5631 folders: vec![],
5632 repo: Some("solo".to_string()),
5633 title: Some("solo".to_string()),
5634 pid: None,
5635 last_seen: now,
5636 },
5637 WindowEntry {
5640 key: "k1".to_string(),
5641 folders: vec![PathBuf::from("/tmp/a")],
5642 repo: Some("repo".to_string()),
5643 title: Some("a branch".to_string()),
5644 pid: None,
5645 last_seen: now,
5646 },
5647 ];
5648 let items = window_menu_items(&entries);
5649 assert_eq!(items.len(), 2);
5651 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5652
5653 let action = items
5656 .iter()
5657 .find_map(|i| match i {
5658 MenuItem::Action(a) => Some(a),
5659 _ => None,
5660 })
5661 .expect("a focus action");
5662 assert_eq!(action.id, "focus:k1");
5663 assert_eq!(action.label, "repo · a branch");
5664
5665 let labels: Vec<&str> = items
5667 .iter()
5668 .filter_map(|i| match i {
5669 MenuItem::Label(t) => Some(t.as_str()),
5670 _ => None,
5671 })
5672 .collect();
5673 assert_eq!(labels, vec!["solo"]);
5674 }
5675
5676 #[tokio::test]
5677 async fn menu_and_status_shapes() {
5678 let svc = WorktreesService::new();
5679 let menu = svc.menu();
5681 assert_eq!(menu.title, "Worktrees");
5682 assert!(matches!(
5683 menu.items.first(),
5684 Some(MenuItem::Label(text)) if text == "No open windows"
5685 ));
5686 let status = svc.status().await;
5687 assert_eq!(status.name, "worktrees");
5688 assert!(status.healthy);
5689 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5690
5691 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5694 .await
5695 .unwrap();
5696 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5697 .await
5698 .unwrap();
5699 svc.handle(
5700 "register",
5701 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5702 )
5703 .await
5704 .unwrap();
5705 let status = svc.status().await;
5706 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5707
5708 let menu = svc.menu();
5709 assert_eq!(menu.items.len(), 3);
5711 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5712 let action_ids: Vec<&str> = menu
5713 .items
5714 .iter()
5715 .filter_map(|i| match i {
5716 MenuItem::Action(a) => Some(a.id.as_str()),
5717 _ => None,
5718 })
5719 .collect();
5720 assert!(action_ids.contains(&"focus:w1"));
5723 assert!(action_ids.contains(&"focus:w2"));
5724 assert!(!action_ids.contains(&"focus:w3"));
5725 }
5726
5727 #[test]
5728 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5729 let svc = WorktreesService::new();
5732 svc.start_menu_refresh();
5733 assert!(svc.refresh.lock().unwrap().is_none());
5734 }
5735
5736 #[tokio::test]
5737 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5738 let svc = WorktreesService::new();
5739 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5740 .await
5741 .unwrap();
5742 assert!(svc.menu_cache.lock().unwrap().is_none());
5744
5745 svc.start_menu_refresh();
5746 svc.start_menu_refresh();
5748
5749 let mut filled = false;
5751 for _ in 0..100 {
5752 if svc.menu_cache.lock().unwrap().is_some() {
5753 filled = true;
5754 break;
5755 }
5756 tokio::time::sleep(Duration::from_millis(10)).await;
5757 }
5758 assert!(filled, "background refresh should populate the menu cache");
5759
5760 let menu = svc.menu();
5762 assert_eq!(menu.title, "Worktrees");
5763 assert!(menu
5764 .items
5765 .iter()
5766 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5767
5768 svc.shutdown().await;
5770 assert!(svc.refresh.lock().unwrap().is_none());
5771 }
5772
5773 #[tokio::test]
5774 async fn default_constructs_an_empty_service() {
5775 let svc = WorktreesService::default();
5776 let payload = svc.handle("list", Value::Null).await.unwrap();
5777 assert_eq!(payload, json!({ "windows": [] }));
5778 }
5779
5780 #[tokio::test]
5783 async fn subscribe_streams_only_for_the_subscribe_op() {
5784 let svc = WorktreesService::new();
5785 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5789 assert!(svc.subscribe("list", &Value::Null).is_none());
5790 assert!(svc.subscribe("register", &Value::Null).is_none());
5791 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5792 }
5793
5794 #[tokio::test]
5795 async fn subscribe_snapshot_matches_the_tree_op() {
5796 let dir = tempfile::tempdir().unwrap();
5797 let repo = init_repo(dir.path());
5798 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5799 repo.set_head("refs/heads/main").unwrap();
5800
5801 let svc = WorktreesService::new();
5802 let stream = svc
5803 .subscribe("subscribe", &Value::Null)
5804 .expect("subscribe stream");
5805 assert_eq!(
5808 stream.snapshot().await,
5809 json!({ "repos": [], "show_closed": true })
5810 );
5811
5812 svc.handle(
5815 "register",
5816 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5817 )
5818 .await
5819 .unwrap();
5820 let snap = stream.snapshot().await;
5821 let tree = svc.handle("tree", Value::Null).await.unwrap();
5822 assert_eq!(snap, tree);
5823 let repos = snap["repos"].as_array().expect("repos array");
5824 assert_eq!(repos.len(), 1);
5825 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5826 }
5827
5828 #[tokio::test]
5829 async fn subscribe_changed_wakes_on_register() {
5830 let svc = WorktreesService::new();
5831 let mut stream = svc
5832 .subscribe("subscribe", &Value::Null)
5833 .expect("subscribe stream");
5834 tokio::select! {
5836 () = stream.changed() => panic!("changed resolved with no registry change"),
5837 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5838 }
5839 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5841 .await
5842 .unwrap();
5843 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5844 .await
5845 .expect("changed should resolve after a register");
5846 }
5847
5848 #[tokio::test]
5851 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5852 let reg = Arc::new(WorktreesRegistry::new());
5853 let cache = TreeSnapshotCache::with_ttl(
5855 reg,
5856 Arc::new(PrStatusCache::new()),
5857 Duration::from_secs(60),
5858 );
5859 let first = cache.snapshot().await;
5861 assert_eq!(cache.compute_count(), 1);
5862 let second = cache.snapshot().await;
5865 assert_eq!(
5866 cache.compute_count(),
5867 1,
5868 "an unchanged read must not rebuild"
5869 );
5870 assert_eq!(first, second);
5871 }
5872
5873 #[tokio::test]
5874 async fn tree_cache_single_flights_a_read_burst() {
5875 let reg = Arc::new(WorktreesRegistry::new());
5876 let cache = Arc::new(TreeSnapshotCache::with_ttl(
5877 reg,
5878 Arc::new(PrStatusCache::new()),
5879 Duration::from_secs(60),
5880 ));
5881 let mut handles = Vec::new();
5885 for _ in 0..16 {
5886 let cache = cache.clone();
5887 handles.push(tokio::spawn(async move { cache.snapshot().await }));
5888 }
5889 let mut results = Vec::new();
5890 for handle in handles {
5891 results.push(handle.await.unwrap());
5892 }
5893 assert_eq!(
5894 cache.compute_count(),
5895 1,
5896 "a concurrent read burst must build the tree once"
5897 );
5898 assert!(
5899 results.windows(2).all(|w| w[0] == w[1]),
5900 "every reader must observe the identical snapshot"
5901 );
5902 }
5903
5904 #[tokio::test]
5905 async fn tree_cache_rebuilds_on_registry_change() {
5906 let reg = Arc::new(WorktreesRegistry::new());
5907 let cache = TreeSnapshotCache::with_ttl(
5908 reg.clone(),
5909 Arc::new(PrStatusCache::new()),
5910 Duration::from_secs(60),
5911 );
5912 cache.snapshot().await;
5913 assert_eq!(cache.compute_count(), 1);
5914 assert!(reg.set_show_closed(false));
5918 cache.snapshot().await;
5919 assert_eq!(
5920 cache.compute_count(),
5921 2,
5922 "a generation bump must force a rebuild"
5923 );
5924 }
5925
5926 #[tokio::test]
5927 async fn tree_cache_rebuilds_after_ttl_expiry() {
5928 let reg = Arc::new(WorktreesRegistry::new());
5929 let cache =
5932 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5933 cache.snapshot().await;
5934 cache.snapshot().await;
5935 assert_eq!(
5936 cache.compute_count(),
5937 2,
5938 "an expired TTL must force a rebuild each read"
5939 );
5940 }
5941
5942 #[tokio::test]
5943 async fn subscribe_streams_share_one_build_per_generation() {
5944 let svc = WorktreesService::new();
5945 let s1 = svc
5946 .subscribe("subscribe", &Value::Null)
5947 .expect("subscribe stream");
5948 let s2 = svc
5949 .subscribe("subscribe", &Value::Null)
5950 .expect("subscribe stream");
5951 let a = s1.snapshot().await;
5954 let b = s2.snapshot().await;
5955 assert_eq!(a, b);
5956 assert_eq!(
5957 svc.tree_cache.compute_count(),
5958 1,
5959 "N streams on one generation must share a single build"
5960 );
5961 }
5962
5963 #[tokio::test]
5966 async fn set_show_closed_toggles_the_snapshot_field() {
5967 let svc = WorktreesService::new();
5968 assert_eq!(
5970 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5971 json!(true)
5972 );
5973 let reply = svc
5975 .handle("set-show-closed", json!({ "show_closed": false }))
5976 .await
5977 .unwrap();
5978 assert_eq!(reply, json!({ "ok": true }));
5979 assert_eq!(
5980 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5981 json!(false)
5982 );
5983 }
5984
5985 #[tokio::test]
5986 async fn set_show_closed_rejects_a_non_boolean_payload() {
5987 let svc = WorktreesService::new();
5988 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5989 assert!(svc
5990 .handle("set-show-closed", json!({ "show_closed": "yes" }))
5991 .await
5992 .is_err());
5993 }
5994
5995 #[tokio::test]
5996 async fn set_show_closed_wakes_the_subscription() {
5997 let svc = WorktreesService::new();
5998 let mut stream = svc
5999 .subscribe("subscribe", &Value::Null)
6000 .expect("subscribe stream");
6001 svc.handle("set-show-closed", json!({ "show_closed": false }))
6003 .await
6004 .unwrap();
6005 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6006 .await
6007 .expect("changed should resolve after a toggle flip");
6008 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6010 }
6011
6012 #[tokio::test]
6013 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6014 let dir = tempfile::tempdir().unwrap();
6017 github_repo(dir.path());
6018 let svc = WorktreesService::new();
6019 svc.handle(
6020 "register",
6021 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6022 )
6023 .await
6024 .unwrap();
6025
6026 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6027 assert!(
6028 repo.get("polling_enabled").is_none(),
6029 "default off omits the flag: {repo:?}"
6030 );
6031
6032 let reply = svc
6033 .handle(
6034 "set-polling",
6035 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6036 )
6037 .await
6038 .unwrap();
6039 assert_eq!(reply, json!({ "ok": true }));
6040 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6041 assert_eq!(repo["polling_enabled"], json!(true));
6042
6043 svc.handle(
6044 "set-polling",
6045 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6046 )
6047 .await
6048 .unwrap();
6049 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6050 assert!(repo.get("polling_enabled").is_none());
6051 }
6052
6053 #[tokio::test]
6054 async fn set_polling_rejects_missing_or_empty_fields() {
6055 let svc = WorktreesService::new();
6056 assert!(svc
6058 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6059 .await
6060 .is_err());
6061 assert!(svc
6063 .handle("set-polling", json!({ "enabled": true }))
6064 .await
6065 .is_err());
6066 assert!(svc
6068 .handle(
6069 "set-polling",
6070 json!({ "owner": " ", "name": "n", "enabled": true })
6071 )
6072 .await
6073 .is_err());
6074 }
6075
6076 #[tokio::test]
6077 async fn set_polling_wakes_the_subscription() {
6078 let dir = tempfile::tempdir().unwrap();
6079 github_repo(dir.path());
6080 let svc = WorktreesService::new();
6081 svc.handle(
6082 "register",
6083 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6084 )
6085 .await
6086 .unwrap();
6087 let mut stream = svc
6088 .subscribe("subscribe", &Value::Null)
6089 .expect("subscribe stream");
6090 svc.handle(
6091 "set-polling",
6092 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6093 )
6094 .await
6095 .unwrap();
6096 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6097 .await
6098 .expect("changed should resolve after enabling a repo");
6099 let repo = repos_of(&stream.snapshot().await)[0].clone();
6100 assert_eq!(repo["polling_enabled"], json!(true));
6101 }
6102
6103 #[tokio::test]
6104 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6105 let dir = tempfile::tempdir().unwrap();
6109 let repo = github_repo(dir.path());
6110 let head = repo.head().unwrap().target().unwrap().to_string();
6111 let svc = WorktreesService::new();
6112 svc.handle(
6113 "register",
6114 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6115 )
6116 .await
6117 .unwrap();
6118 svc.registry.set_polling("rust-works", "omni-dev", true);
6119
6120 let mut badges = HashMap::new();
6121 badges.insert(
6122 PrTarget {
6123 owner: "rust-works".into(),
6124 name: "omni-dev".into(),
6125 branch: "main".into(),
6126 },
6127 pr(pending_badge(7, &head)),
6128 );
6129 svc.pr_cache.replace(badges);
6130
6131 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6132 assert_eq!(wt["pr"]["number"], json!(7));
6133
6134 svc.handle(
6135 "set-polling",
6136 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6137 )
6138 .await
6139 .unwrap();
6140 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6141 assert!(
6142 wt.get("pr").is_none(),
6143 "a disabled repo must carry no badge: {wt:?}"
6144 );
6145 }
6146
6147 #[tokio::test]
6148 async fn an_expired_lease_drops_the_flag_and_badges() {
6149 let dir = tempfile::tempdir().unwrap();
6153 let repo = github_repo(dir.path());
6154 let head = repo.head().unwrap().target().unwrap().to_string();
6155 let svc = WorktreesService::new();
6156 svc.handle(
6157 "register",
6158 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6159 )
6160 .await
6161 .unwrap();
6162 svc.registry.set_polling("rust-works", "omni-dev", true);
6163 let mut badges = HashMap::new();
6164 badges.insert(
6165 PrTarget {
6166 owner: "rust-works".into(),
6167 name: "omni-dev".into(),
6168 branch: "main".into(),
6169 },
6170 pr(pending_badge(7, &head)),
6171 );
6172 svc.pr_cache.replace(badges);
6173
6174 let snap = svc.handle("tree", Value::Null).await.unwrap();
6176 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6177 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6178 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6179
6180 svc.registry.set_polling_expiry(
6182 "rust-works",
6183 "omni-dev",
6184 Utc::now() - chrono::Duration::minutes(1),
6185 );
6186
6187 let snap = svc.handle("tree", Value::Null).await.unwrap();
6188 let repo0 = &repos_of(&snap)[0];
6189 assert!(
6190 repo0.get("polling_enabled").is_none(),
6191 "expired lease drops the flag: {repo0:?}"
6192 );
6193 assert!(
6194 repo0["worktrees"][0].get("pr").is_none(),
6195 "expired lease drops the badge"
6196 );
6197 assert!(
6198 pr_targets_from_snapshot(&snap).is_empty(),
6199 "the poller no longer watches an expired repo"
6200 );
6201 }
6202
6203 #[tokio::test]
6204 async fn polling_prefs_persist_across_reloads_with_0600() {
6205 let dir = tempfile::tempdir().unwrap();
6208 let prefs = dir.path().join("worktrees-polling.json");
6209
6210 let svc = WorktreesService::new();
6211 svc.load_polling_prefs(prefs.clone());
6212 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6213 svc.handle(
6214 "set-polling",
6215 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6216 )
6217 .await
6218 .unwrap();
6219 assert!(prefs.exists());
6220 #[cfg(unix)]
6221 {
6222 use std::os::unix::fs::PermissionsExt;
6223 assert_eq!(
6224 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6225 0o600
6226 );
6227 }
6228
6229 let svc2 = WorktreesService::new();
6231 svc2.load_polling_prefs(prefs.clone());
6232 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6233
6234 svc2.handle(
6236 "set-polling",
6237 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6238 )
6239 .await
6240 .unwrap();
6241 let svc3 = WorktreesService::new();
6242 svc3.load_polling_prefs(prefs);
6243 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6244 }
6245
6246 #[test]
6247 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6248 let dir = tempfile::tempdir().unwrap();
6253
6254 let corrupt = dir.path().join("worktrees-polling.json");
6256 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6257 let svc = WorktreesService::new();
6258 svc.load_polling_prefs(corrupt);
6259 assert!(svc.registry.enabled_polling_repos().is_empty());
6260
6261 let as_dir = dir.path().join("is-a-directory");
6263 std::fs::create_dir(&as_dir).unwrap();
6264 let svc2 = WorktreesService::new();
6265 svc2.load_polling_prefs(as_dir);
6266 assert!(svc2.registry.enabled_polling_repos().is_empty());
6267 }
6268
6269 #[tokio::test]
6270 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6271 let dir = tempfile::tempdir().unwrap();
6274 github_repo(dir.path());
6275 let bin_dir = tempfile::tempdir().unwrap();
6276 let marker = bin_dir.path().join("spawned");
6277 let fake = bin_dir.path().join("fake-gh");
6278 std::fs::write(
6279 &fake,
6280 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6281 )
6282 .unwrap();
6283 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6284 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6285 std::fs::set_permissions(&fake, perms).unwrap();
6286
6287 let svc = WorktreesService::new();
6288 svc.handle(
6289 "register",
6290 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6291 )
6292 .await
6293 .unwrap();
6294 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6296 tokio::time::sleep(Duration::from_millis(200)).await;
6297 svc.shutdown().await;
6298 assert!(
6299 !marker.exists(),
6300 "a registered-but-not-enabled repo must drive zero gh"
6301 );
6302 }
6303
6304 #[tokio::test]
6305 async fn menu_action_rejects_unknown_and_missing_window() {
6306 let svc = WorktreesService::new();
6307 assert!(svc.menu_action("bogus").await.is_err());
6308 assert!(svc.menu_action("focus:nope").await.is_err());
6310 svc.shutdown().await;
6311 }
6312
6313 struct VscodeBinGuard(Option<std::ffi::OsString>);
6320 impl Drop for VscodeBinGuard {
6321 fn drop(&mut self) {
6322 match self.0.take() {
6323 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6324 None => std::env::remove_var(VSCODE_BIN_ENV),
6325 }
6326 }
6327 }
6328
6329 #[tokio::test]
6330 async fn menu_action_focus_resolves_folder_and_spawns() {
6331 let dir = tempfile::tempdir().unwrap();
6332 let svc = WorktreesService::new();
6333 svc.handle(
6334 "register",
6335 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6336 )
6337 .await
6338 .unwrap();
6339
6340 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6343 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6344 svc.menu_action("focus:w1").await.unwrap();
6345 }
6346
6347 #[tokio::test]
6348 async fn open_rejects_missing_relative_or_nonexistent_path() {
6349 let svc = WorktreesService::new();
6350 assert!(svc.handle("open", json!({})).await.is_err());
6352 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6353 assert!(svc
6356 .handle("open", json!({ "path": "relative/dir" }))
6357 .await
6358 .is_err());
6359 assert!(svc
6360 .handle("open", json!({ "path": "-flag" }))
6361 .await
6362 .is_err());
6363 assert!(svc
6366 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6367 .await
6368 .is_err());
6369 svc.shutdown().await;
6370 }
6371
6372 #[tokio::test]
6373 async fn open_focuses_an_existing_absolute_dir() {
6374 let dir = tempfile::tempdir().unwrap();
6375 let svc = WorktreesService::new();
6376 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6381 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6382 let reply = svc
6383 .handle("open", json!({ "path": dir.path() }))
6384 .await
6385 .unwrap();
6386 assert_eq!(reply, json!({ "ok": true }));
6387 svc.shutdown().await;
6388 }
6389
6390 #[test]
6391 fn focus_window_with_validates_folder_then_spawns() {
6392 let dir = tempfile::tempdir().unwrap();
6393 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6395 assert!(
6396 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6397 );
6398 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6400 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6402 }
6403
6404 #[test]
6405 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6406 assert_eq!(
6408 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6409 PathBuf::from("/custom/code")
6410 );
6411 let existing = tempfile::NamedTempFile::new().unwrap();
6413 let existing_path = existing.path().to_str().unwrap();
6414 assert_eq!(
6415 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6416 PathBuf::from(existing_path)
6417 );
6418 assert_eq!(
6420 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6421 PathBuf::from("code")
6422 );
6423 let _ = resolve_code_binary();
6425 }
6426
6427 fn init_repo(dir: &Path) -> Repository {
6432 let repo = Repository::init(dir).unwrap();
6433 let mut cfg = repo.config().unwrap();
6434 cfg.set_str("user.name", "Test").unwrap();
6435 cfg.set_str("user.email", "test@example.com").unwrap();
6436 repo
6437 }
6438
6439 fn empty_commit(
6442 repo: &Repository,
6443 refname: Option<&str>,
6444 parents: &[&git2::Commit<'_>],
6445 msg: &str,
6446 ) -> git2::Oid {
6447 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6448 let tree = repo
6449 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6450 .unwrap();
6451 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6452 .unwrap()
6453 }
6454
6455 fn commit_file(
6460 repo: &Repository,
6461 refname: &str,
6462 name: &str,
6463 content: &[u8],
6464 msg: &str,
6465 ) -> git2::Oid {
6466 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6467 let blob = repo.blob(content).unwrap();
6468 let mut builder = repo.treebuilder(None).unwrap();
6469 builder.insert(name, blob, 0o100_644).unwrap();
6470 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6471 let parent = repo
6472 .refname_to_id(refname)
6473 .ok()
6474 .and_then(|oid| repo.find_commit(oid).ok());
6475 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6476 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6477 .unwrap()
6478 }
6479
6480 fn diverging_repo(dir: &Path) -> Repository {
6483 let repo = init_repo(dir);
6484 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6486 let a_commit = repo.find_commit(a).unwrap();
6487 let c = empty_commit(&repo, None, &[&a_commit], "C");
6489 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6490 .unwrap();
6491 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6493 drop(a_commit);
6495 repo.set_head("refs/heads/main").unwrap();
6496 let mut cfg = repo.config().unwrap();
6498 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6499 .unwrap();
6500 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6501 .unwrap();
6502 cfg.set_str("branch.main.remote", "origin").unwrap();
6503 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6504 repo
6505 }
6506
6507 fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6513 let repo = init_repo(dir);
6514 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6515 let a_commit = repo.find_commit(a).unwrap();
6516 let c = empty_commit(&repo, None, &[&a_commit], "C");
6517 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6518 .unwrap();
6519 drop(a_commit);
6520 repo.set_head("refs/heads/main").unwrap();
6521 repo
6522 }
6523
6524 #[test]
6525 fn git_status_reads_branch_and_ahead_behind() {
6526 let dir = tempfile::tempdir().unwrap();
6527 let _repo = diverging_repo(dir.path());
6528 let status = git_status(dir.path());
6529 assert_eq!(status.branch.as_deref(), Some("main"));
6530 assert_eq!(status.ahead, Some(1));
6531 assert_eq!(status.behind, Some(1));
6532 assert_eq!(
6534 status.main_repo.as_deref(),
6535 dir.path().file_name().and_then(|n| n.to_str())
6536 );
6537 assert!(!status.is_worktree);
6538 }
6539
6540 #[test]
6541 fn git_status_empty_repo_is_unborn() {
6542 let dir = tempfile::tempdir().unwrap();
6546 init_repo(dir.path());
6547 let status = git_status(dir.path());
6548 assert_eq!(status.branch, None);
6549 assert_eq!(status.head_sha, None);
6551 assert_eq!(status.ahead, None);
6552 assert_eq!(status.behind, None);
6553 assert_eq!(
6554 status.main_repo.as_deref(),
6555 dir.path().file_name().and_then(|n| n.to_str())
6556 );
6557 assert!(!status.is_worktree);
6558 }
6559
6560 #[test]
6561 fn git_status_no_upstream_reports_branch_only() {
6562 let dir = tempfile::tempdir().unwrap();
6563 let repo = init_repo(dir.path());
6564 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6565 repo.set_head("refs/heads/main").unwrap();
6566 let status = git_status(dir.path());
6567 assert_eq!(status.branch.as_deref(), Some("main"));
6568 assert_eq!(status.ahead, None);
6570 assert_eq!(status.behind, None);
6571 assert_eq!(status.upstream_sha, None);
6574 }
6575
6576 #[test]
6577 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6578 let plain = tempfile::tempdir().unwrap();
6580 assert_eq!(git_status(plain.path()), GitStatus::default());
6581
6582 let dir = tempfile::tempdir().unwrap();
6585 let repo = init_repo(dir.path());
6586 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6587 repo.set_head_detached(a).unwrap();
6588 let status = git_status(dir.path());
6589 assert_eq!(status.branch, None);
6590 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6593 assert_eq!(status.ahead, None);
6594 assert_eq!(status.behind, None);
6595 assert_eq!(status.upstream_sha, None);
6598 assert_eq!(
6599 status.main_repo.as_deref(),
6600 dir.path().file_name().and_then(|n| n.to_str())
6601 );
6602 assert!(!status.is_worktree);
6603 }
6604
6605 #[test]
6608 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6609 let dir = tempfile::tempdir().unwrap();
6613 let repo = diverging_repo(dir.path());
6614 let status = git_status_cheap(dir.path());
6615 assert_eq!(status.branch.as_deref(), Some("main"));
6616 assert_eq!(status.ahead, None);
6617 assert_eq!(status.behind, None);
6618 assert_eq!(
6619 status.main_repo.as_deref(),
6620 dir.path().file_name().and_then(|n| n.to_str())
6621 );
6622 let head = repo.head().unwrap().target().unwrap();
6625 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6626 }
6627
6628 #[test]
6631 fn git_status_head_sha_tracks_new_commits() {
6632 let dir = tempfile::tempdir().unwrap();
6638 let repo = init_repo(dir.path());
6639 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6640 repo.set_head("refs/heads/main").unwrap();
6641 let before = git_status_cheap(dir.path());
6642 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6643
6644 let head = repo.find_commit(a).unwrap();
6645 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6646 let after = git_status_cheap(dir.path());
6647 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6648 assert_ne!(before.head_sha, after.head_sha);
6649 assert_eq!(before.branch, after.branch);
6652 }
6653
6654 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6660 repo.reference("refs/remotes/origin/main", oid, true, "push")
6661 .unwrap();
6662 }
6663
6664 #[test]
6665 fn git_status_upstream_sha_tracks_a_push() {
6666 let dir = tempfile::tempdir().unwrap();
6673 let repo = diverging_repo(dir.path());
6674 let before = git_status(dir.path());
6675 assert_eq!(before.ahead, Some(1));
6676 assert_eq!(before.behind, Some(1));
6677
6678 let head = repo.head().unwrap().target().unwrap();
6679 simulate_push(&repo, head);
6680 let after = git_status(dir.path());
6681
6682 assert_eq!(
6684 after.upstream_sha.as_deref(),
6685 Some(head.to_string().as_str())
6686 );
6687 assert_ne!(before.upstream_sha, after.upstream_sha);
6688 assert_eq!(after.ahead, Some(0));
6689 assert_eq!(after.behind, Some(0));
6690 assert_eq!(before.branch, after.branch);
6694 assert_eq!(before.head_sha, after.head_sha);
6695 }
6696
6697 #[test]
6698 fn git_status_cheap_reports_upstream_sha() {
6699 let dir = tempfile::tempdir().unwrap();
6704 let repo = diverging_repo(dir.path());
6705 let status = git_status_cheap(dir.path());
6706 let upstream = repo
6707 .find_branch("origin/main", git2::BranchType::Remote)
6708 .unwrap()
6709 .get()
6710 .target()
6711 .unwrap();
6712 assert_eq!(
6713 status.upstream_sha.as_deref(),
6714 Some(upstream.to_string().as_str())
6715 );
6716 assert_eq!(status.ahead, None);
6717 assert_eq!(status.behind, None);
6718 }
6719
6720 #[test]
6721 fn folder_ahead_behind_computes_divergence_and_degrades() {
6722 let dir = tempfile::tempdir().unwrap();
6724 let _repo = diverging_repo(dir.path());
6725 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6726
6727 let no_up = tempfile::tempdir().unwrap();
6729 let repo = init_repo(no_up.path());
6730 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6731 repo.set_head("refs/heads/main").unwrap();
6732 assert_eq!(folder_ahead_behind(no_up.path()), None);
6733
6734 let detached = tempfile::tempdir().unwrap();
6736 let drepo = init_repo(detached.path());
6737 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6738 drepo.set_head_detached(a).unwrap();
6739 assert_eq!(folder_ahead_behind(detached.path()), None);
6740 let plain = tempfile::tempdir().unwrap();
6741 assert_eq!(folder_ahead_behind(plain.path()), None);
6742 }
6743
6744 #[test]
6747 fn folder_main_behind_computes_divergence_and_degrades() {
6748 let dir = tempfile::tempdir().unwrap();
6752 let _repo = behind_main_no_upstream_repo(dir.path());
6753 assert_eq!(folder_main_behind(dir.path()), Some(1));
6754
6755 let detached = tempfile::tempdir().unwrap();
6757 let drepo = init_repo(detached.path());
6758 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6759 drepo.set_head_detached(a).unwrap();
6760 assert_eq!(folder_main_behind(detached.path()), None);
6761 let plain = tempfile::tempdir().unwrap();
6762 assert_eq!(folder_main_behind(plain.path()), None);
6763 }
6764
6765 #[test]
6766 fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6767 let dir = tempfile::tempdir().unwrap();
6772 let _repo = diverging_repo(dir.path());
6773 assert_eq!(folder_main_behind(dir.path()), None);
6774 }
6775
6776 #[test]
6777 fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6778 let dir = tempfile::tempdir().unwrap();
6780 let repo = init_repo(dir.path());
6781 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6782 repo.set_head("refs/heads/main").unwrap();
6783 assert_eq!(folder_main_behind(dir.path()), None);
6784 }
6785
6786 #[test]
6787 fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6788 let dir = tempfile::tempdir().unwrap();
6789 let repo = init_repo(dir.path());
6790 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6791 let base_commit = repo.find_commit(base).unwrap();
6792
6793 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6795 let m1_commit = repo.find_commit(m1).unwrap();
6796 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6797 let m2_commit = repo.find_commit(m2).unwrap();
6798 let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6799 repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6800 .unwrap();
6801
6802 let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6805 repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6806 .unwrap();
6807 empty_commit(
6808 &repo,
6809 Some("refs/heads/feature"),
6810 &[&base_commit],
6811 "local-feature",
6812 );
6813 drop(base_commit);
6814 drop(m1_commit);
6815 drop(m2_commit);
6816
6817 repo.set_head("refs/heads/feature").unwrap();
6818 let mut cfg = repo.config().unwrap();
6819 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6820 .unwrap();
6821 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6822 .unwrap();
6823 cfg.set_str("branch.feature.remote", "origin").unwrap();
6824 cfg.set_str("branch.feature.merge", "refs/heads/feature")
6825 .unwrap();
6826
6827 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6828 assert_eq!(folder_main_behind(dir.path()), Some(3));
6829 }
6830
6831 #[tokio::test]
6832 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6833 let diverging = tempfile::tempdir().unwrap();
6834 let _d = diverging_repo(diverging.path());
6835 let no_up = tempfile::tempdir().unwrap();
6836 let repo = init_repo(no_up.path());
6837 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6838 repo.set_head("refs/heads/main").unwrap();
6839
6840 let svc = WorktreesService::new();
6841 let diverging_path = diverging.path().display().to_string();
6842 let no_up_path = no_up.path().display().to_string();
6843 let reply = svc
6844 .handle(
6845 "ahead-behind",
6846 json!({ "paths": [&diverging_path, &no_up_path] }),
6847 )
6848 .await
6849 .unwrap();
6850 let results = reply.get("results").unwrap();
6851 let d = results.get(diverging_path.as_str()).unwrap();
6855 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6856 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6857 assert!(d.get("main_behind").is_none(), "{d:?}");
6858 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6862
6863 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6865 assert_eq!(empty.get("results"), Some(&json!({})));
6866 }
6867
6868 #[tokio::test]
6869 async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6870 let dir = tempfile::tempdir().unwrap();
6871 let _repo = behind_main_no_upstream_repo(dir.path());
6872 let svc = WorktreesService::new();
6873 let path = dir.path().display().to_string();
6874 let reply = svc
6875 .handle("ahead-behind", json!({ "paths": [&path] }))
6876 .await
6877 .unwrap();
6878 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6879 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6880 assert!(entry.get("ahead").is_none(), "{entry:?}");
6882 assert!(entry.get("behind").is_none(), "{entry:?}");
6883 }
6884
6885 #[tokio::test]
6886 async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6887 let dir = tempfile::tempdir().unwrap();
6892 let repo = init_repo(dir.path());
6893 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6894 let base_commit = repo.find_commit(base).unwrap();
6895
6896 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6897 let m1_commit = repo.find_commit(m1).unwrap();
6898 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6899 repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6900 .unwrap();
6901
6902 let r = empty_commit(
6903 &repo,
6904 Some("refs/heads/release"),
6905 &[&base_commit],
6906 "release",
6907 );
6908 repo.reference("refs/remotes/origin/release", r, true, "origin release")
6909 .unwrap();
6910 drop(base_commit);
6911 drop(m1_commit);
6912
6913 repo.set_head("refs/heads/release").unwrap();
6914 let mut cfg = repo.config().unwrap();
6915 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6916 .unwrap();
6917 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6918 .unwrap();
6919 cfg.set_str("branch.release.remote", "origin").unwrap();
6920 cfg.set_str("branch.release.merge", "refs/heads/release")
6921 .unwrap();
6922
6923 let svc = WorktreesService::new();
6924 let path = dir.path().display().to_string();
6925 let reply = svc
6926 .handle("ahead-behind", json!({ "paths": [&path] }))
6927 .await
6928 .unwrap();
6929 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6930 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6931 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6932 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6933 }
6934
6935 #[tokio::test]
6936 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6937 let dir = tempfile::tempdir().unwrap();
6939 let _repo = diverging_repo(dir.path());
6940 let svc = WorktreesService::new();
6941 svc.handle(
6942 "register",
6943 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6944 )
6945 .await
6946 .unwrap();
6947
6948 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6949 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6950 let main_wt = &worktrees[0];
6951 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6954 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6955 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6956 }
6957
6958 #[tokio::test]
6959 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6960 let dir = tempfile::tempdir().unwrap();
6965 let repo = init_repo(dir.path());
6966 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6967 repo.set_head("refs/heads/main").unwrap();
6968
6969 let svc = WorktreesService::new();
6970 svc.handle(
6971 "register",
6972 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6973 )
6974 .await
6975 .unwrap();
6976
6977 let before = svc.handle("tree", Value::Null).await.unwrap();
6978 let wt = &repos_of(&before)[0]["worktrees"][0];
6979 assert_eq!(
6980 wt.get("head_sha").and_then(Value::as_str),
6981 Some(a.to_string().as_str())
6982 );
6983
6984 let head = repo.find_commit(a).unwrap();
6987 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6988 let after = svc.handle("tree", Value::Null).await.unwrap();
6989 assert_eq!(
6990 repos_of(&after)[0]["worktrees"][0]
6991 .get("head_sha")
6992 .and_then(Value::as_str),
6993 Some(b.to_string().as_str())
6994 );
6995 assert_ne!(before, after, "a commit must be a visible snapshot delta");
6996 }
6997
6998 #[tokio::test]
6999 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7000 let dir = tempfile::tempdir().unwrap();
7003 init_repo(dir.path());
7004 let svc = WorktreesService::new();
7005 svc.handle(
7006 "register",
7007 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7008 )
7009 .await
7010 .unwrap();
7011 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7012 assert!(wt.get("head_sha").is_none(), "{wt:?}");
7013 }
7014
7015 #[tokio::test]
7018 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7019 let dir = tempfile::tempdir().unwrap();
7025 let repo = diverging_repo(dir.path());
7026 let svc = WorktreesService::new();
7027 svc.handle(
7028 "register",
7029 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7030 )
7031 .await
7032 .unwrap();
7033
7034 let before = svc.handle("tree", Value::Null).await.unwrap();
7035 let head = repo.head().unwrap().target().unwrap();
7036 assert_ne!(
7037 repos_of(&before)[0]["worktrees"][0]
7038 .get("upstream_sha")
7039 .and_then(Value::as_str),
7040 Some(head.to_string().as_str()),
7041 "the fixture must start un-pushed for this to prove anything"
7042 );
7043
7044 simulate_push(&repo, head);
7046 let after = svc.handle("tree", Value::Null).await.unwrap();
7047 let wt = &repos_of(&after)[0]["worktrees"][0];
7048 assert_eq!(
7049 wt.get("upstream_sha").and_then(Value::as_str),
7050 Some(head.to_string().as_str())
7051 );
7052 assert_eq!(
7055 wt.get("head_sha").and_then(Value::as_str),
7056 repos_of(&before)[0]["worktrees"][0]
7057 .get("head_sha")
7058 .and_then(Value::as_str)
7059 );
7060 assert_ne!(before, after, "a push must be a visible snapshot delta");
7061 }
7062
7063 #[tokio::test]
7064 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7065 let dir = tempfile::tempdir().unwrap();
7069 let repo = init_repo(dir.path());
7070 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7071 repo.set_head("refs/heads/main").unwrap();
7072 let svc = WorktreesService::new();
7073 svc.handle(
7074 "register",
7075 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7076 )
7077 .await
7078 .unwrap();
7079 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7080 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7081 assert!(wt.get("head_sha").is_some(), "{wt:?}");
7083 }
7084
7085 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7095 let guard = shim_lock();
7096 let path = dir.join("fake-gh");
7097 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7098 (path, guard)
7099 }
7100
7101 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7106 let guard = shim_lock();
7107 let path = dir.join("fake-gh");
7108 let counter = dir.join("gh-calls");
7109 write_exec_script(
7110 &path,
7111 &format!(
7112 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7113 counter = counter.display()
7114 ),
7115 );
7116 (path, guard, counter)
7117 }
7118
7119 fn gh_spawn_count(counter: &Path) -> usize {
7122 std::fs::read(counter).map_or(0, |b| b.len())
7123 }
7124
7125 fn counted_gh_records(log: &Path) -> usize {
7130 std::fs::read_to_string(log)
7131 .unwrap_or_default()
7132 .lines()
7133 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7134 .count()
7135 }
7136
7137 fn github_repo(dir: &Path) -> Repository {
7139 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7140 }
7141
7142 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7145 let repo = init_repo(dir);
7146 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7147 repo.set_head("refs/heads/main").unwrap();
7148 repo.remote("origin", url).unwrap();
7149 repo
7150 }
7151
7152 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7157 PrBadge {
7158 number,
7159 is_draft: false,
7160 checks: PrCheckState::Pending,
7161 url: "u".into(),
7162 head_oid: head_oid.to_string(),
7163 }
7164 }
7165
7166 fn pr(badge: PrBadge) -> PrResolution {
7168 PrResolution::Pr(badge)
7169 }
7170
7171 #[test]
7172 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7173 let snapshot = json!({"repos":[
7174 {
7175 "main_repo":"omni-dev",
7176 "github":{"owner":"rust-works","name":"omni-dev"},
7177 "root":"/r",
7178 "polling_enabled":true,
7181 "worktrees":[
7183 {"path":"/r","branch":"main","is_main":true,"open":true},
7184 {"path":"/w1","branch":"main","is_main":false,"open":true},
7185 {"path":"/w2","branch":"feature","is_main":false,"open":true},
7186 {"path":"/w3","is_main":false,"open":true}
7188 ]
7189 },
7190 {
7191 "main_repo":"local","root":"/l",
7193 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7194 }
7195 ]});
7196 let targets = pr_targets_from_snapshot(&snapshot);
7197 assert_eq!(
7198 targets,
7199 vec![
7200 PrTarget {
7201 owner: "rust-works".into(),
7202 name: "omni-dev".into(),
7203 branch: "feature".into()
7204 },
7205 PrTarget {
7206 owner: "rust-works".into(),
7207 name: "omni-dev".into(),
7208 branch: "main".into()
7209 },
7210 ]
7211 );
7212 }
7213
7214 #[test]
7215 fn pr_targets_from_snapshot_is_empty_without_repos() {
7216 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7217 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7218 }
7219
7220 #[test]
7221 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7222 for github in [
7225 json!({}),
7226 json!({"owner": "o"}),
7227 json!({"owner": 1, "name": 2}),
7228 ] {
7229 let snapshot = json!({"repos":[{
7230 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7231 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7232 }]});
7233 assert!(
7234 pr_targets_from_snapshot(&snapshot).is_empty(),
7235 "{snapshot:?}"
7236 );
7237 }
7238 }
7239
7240 #[test]
7241 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7242 for repo in [
7246 json!({
7247 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7248 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7249 }),
7250 json!({
7251 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7252 "polling_enabled":false,
7253 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7254 }),
7255 ] {
7256 let snapshot = json!({ "repos": [repo] });
7257 assert!(
7258 pr_targets_from_snapshot(&snapshot).is_empty(),
7259 "not-polled repo must yield no targets: {snapshot:?}"
7260 );
7261 }
7262 let enabled = json!({"repos":[{
7264 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7265 "polling_enabled":true,
7266 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7267 }]});
7268 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7269 }
7270
7271 #[test]
7272 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7273 let backoff = Duration::from_secs(600);
7274 assert!(pr_should_fetch(false, None, backoff));
7276 assert!(!pr_should_fetch(
7279 false,
7280 Some(Duration::from_secs(1)),
7281 backoff
7282 ));
7283 assert!(pr_should_fetch(false, Some(backoff), backoff));
7285 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7286 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7290 assert!(pr_should_fetch(
7291 true,
7292 Some(Duration::from_millis(1)),
7293 backoff
7294 ));
7295 }
7296
7297 #[test]
7298 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7299 let base = Duration::from_secs(10);
7300 let fresh = Some(Duration::ZERO);
7301 let stale = Some(PENDING_FAST_WINDOW);
7302 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7305 assert_eq!(
7306 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7307 base
7308 );
7309 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7312 assert_eq!(
7313 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7314 PENDING_MAX_INTERVAL
7315 );
7316 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7319 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7321 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7322 assert_eq!(
7324 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7325 MAX_PR_POLL_INTERVAL
7326 );
7327 assert_eq!(
7328 next_pr_poll_delay(Duration::MAX, base, false, None),
7329 MAX_PR_POLL_INTERVAL
7330 );
7331 }
7332
7333 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7335 PrWatch {
7336 target: PrTarget {
7337 owner: "rust-works".into(),
7338 name: "omni-dev".into(),
7339 branch: branch.into(),
7340 },
7341 upstream_sha: upstream.map(str::to_string),
7342 }
7343 }
7344
7345 #[test]
7346 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7347 let a = watch("a", Some("111"));
7348 let b = watch("b", Some("222"));
7349 let ab = [a.clone(), b.clone()];
7350 let just_a = std::slice::from_ref(&a);
7351 let just_b = std::slice::from_ref(&b);
7352 assert!(!pr_watch_grew(&ab, &ab));
7354 assert!(pr_watch_grew(just_a, &ab));
7356 assert!(!pr_watch_grew(&ab, just_a));
7358 assert!(pr_watch_grew(&[], just_a));
7360 let a_pushed = [watch("a", Some("999"))];
7362 assert!(pr_watch_grew(just_a, &a_pushed));
7363 assert!(pr_watch_grew(just_a, just_b));
7365 }
7366
7367 #[test]
7368 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7369 let base = Duration::from_secs(10);
7370 let over = RateLimitSnapshot {
7371 graphql: Some(rl_resource(90)),
7372 core: Some(rl_resource(3)),
7373 search: None,
7374 };
7375 let under = RateLimitSnapshot {
7376 graphql: Some(rl_resource(50)),
7377 core: Some(rl_resource(3)),
7378 search: None,
7379 };
7380 assert_eq!(budget_throttled_delay(base, None), base);
7382 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7384 assert_eq!(
7386 budget_throttled_delay(base, Some(&over)),
7387 BUDGET_THROTTLE_INTERVAL
7388 );
7389 let long = BUDGET_THROTTLE_INTERVAL * 2;
7391 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7392 }
7393
7394 #[test]
7395 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7396 let target = PrTarget {
7400 owner: "rust-works".into(),
7401 name: "omni-dev".into(),
7402 branch: "main".into(),
7403 };
7404 let badge = PrResolution::Pr(PrBadge {
7405 number: 1337,
7406 is_draft: true,
7407 checks: PrCheckState::Pending,
7408 url: "http://x/1337".into(),
7409 head_oid: "deadbeef".into(),
7410 });
7411 let watched = vec![watch("main", Some("abc"))];
7412 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7413 .unwrap()
7414 .with_timezone(&Utc);
7415 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7416
7417 let json = serde_json::to_vec(&prefs).unwrap();
7418 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7419 assert_eq!(back, prefs);
7420 assert_eq!(back.polled_at, Some(polled_at));
7421 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7422 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7424 }
7425
7426 #[test]
7427 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7428 let target = PrTarget {
7432 owner: "rust-works".into(),
7433 name: "omni-dev".into(),
7434 branch: "feature".into(),
7435 };
7436 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7437 let json = serde_json::to_vec(&prefs).unwrap();
7438 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7439 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7440 assert_eq!(
7441 back.entries[0].resolution.clone().into_resolution(),
7442 PrResolution::NoPr
7443 );
7444 }
7445
7446 #[test]
7447 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7448 let dir = tempfile::tempdir().unwrap();
7453 let path = dir.path().join("pr-cache.json");
7454 let target = PrTarget {
7455 owner: "rust-works".into(),
7456 name: "omni-dev".into(),
7457 branch: "main".into(),
7458 };
7459 let mut prefs = pr_cache_prefs_from(
7460 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7461 &[watch("main", None)],
7462 Utc::now(),
7463 );
7464 prefs.polled_at = None;
7465 write_pr_cache(&path, &prefs).unwrap();
7466
7467 let svc = WorktreesService::new();
7468 svc.load_pr_cache(path);
7469 assert!(
7470 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7471 "the badge itself must still restore"
7472 );
7473 assert!(
7474 svc.pr_warm_start
7475 .lock()
7476 .unwrap_or_else(PoisonError::into_inner)
7477 .is_none(),
7478 "no poll time means a cold start, not a trusted warm one"
7479 );
7480 }
7481
7482 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7486 tracing::subscriber::set_default(
7487 tracing_subscriber::fmt()
7488 .with_max_level(tracing::Level::WARN)
7489 .with_writer(std::io::sink)
7490 .finish(),
7491 )
7492 }
7493
7494 #[test]
7495 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7496 let _trace = warn_subscriber();
7500 let dir = tempfile::tempdir().unwrap();
7501 let corrupt = dir.path().join("pr-cache.json");
7502 std::fs::write(&corrupt, b"not json").unwrap();
7503 let svc = WorktreesService::new();
7504 svc.load_pr_cache(corrupt.clone());
7505 assert!(svc.pr_cache.entries().is_empty());
7506 assert_eq!(
7507 svc.pr_cache_path
7508 .lock()
7509 .unwrap_or_else(PoisonError::into_inner)
7510 .as_deref(),
7511 Some(corrupt.as_path()),
7512 "the path must be stored even when the load fails, so persistence recovers"
7513 );
7514
7515 let svc = WorktreesService::new();
7518 svc.load_pr_cache(dir.path().to_path_buf());
7519 assert!(svc.pr_cache.entries().is_empty());
7520 }
7521
7522 #[test]
7523 fn persist_pr_cache_swallows_a_write_failure() {
7524 let _trace = warn_subscriber();
7528 let dir = tempfile::tempdir().unwrap();
7529 let blocker = dir.path().join("blocker");
7530 std::fs::write(&blocker, b"").unwrap();
7531 let path = blocker.join("pr-cache.json");
7533 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7534 assert!(!path.exists());
7535 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7538 }
7539
7540 #[tokio::test]
7541 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7542 let dir = tempfile::tempdir().unwrap();
7543 let repo = github_repo(dir.path());
7544 let head = repo.head().unwrap().target().unwrap().to_string();
7545 let svc = WorktreesService::new();
7546 svc.handle(
7547 "register",
7548 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7549 )
7550 .await
7551 .unwrap();
7552 svc.registry.set_polling("rust-works", "omni-dev", true);
7555
7556 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7559 assert!(wt.get("pr").is_none(), "{wt:?}");
7560 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7561
7562 let mut badges = HashMap::new();
7564 badges.insert(
7565 PrTarget {
7566 owner: "rust-works".into(),
7567 name: "omni-dev".into(),
7568 branch: "main".into(),
7569 },
7570 pr(pending_badge(1337, &head)),
7571 );
7572 assert!(svc.pr_cache.replace(badges));
7573
7574 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7575 assert_eq!(wt["pr"]["number"], json!(1337));
7576 assert_eq!(wt["pr"]["checks"], json!("pending"));
7577 assert_eq!(wt["pr"]["isDraft"], json!(false));
7579 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7581 }
7582
7583 #[tokio::test]
7584 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7585 let dir = tempfile::tempdir().unwrap();
7590 let repo = github_repo(dir.path());
7591 let head = repo.head().unwrap().target().unwrap();
7592 repo.set_head_detached(head).unwrap();
7593
7594 let svc = WorktreesService::new();
7595 svc.handle(
7596 "register",
7597 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7598 )
7599 .await
7600 .unwrap();
7601 svc.registry.set_polling("rust-works", "omni-dev", true);
7604 let mut badges = HashMap::new();
7607 badges.insert(
7608 PrTarget {
7609 owner: "rust-works".into(),
7610 name: "omni-dev".into(),
7611 branch: "main".into(),
7612 },
7613 pr(pending_badge(1, &head.to_string())),
7614 );
7615 svc.pr_cache.replace(badges);
7616
7617 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7618 assert!(wt.get("branch").is_none(), "{wt:?}");
7619 assert_eq!(
7621 wt.get("head_sha").and_then(Value::as_str),
7622 Some(head.to_string().as_str())
7623 );
7624 assert!(wt.get("pr").is_none(), "{wt:?}");
7625 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7627 }
7628
7629 #[tokio::test]
7630 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7631 let dir = tempfile::tempdir().unwrap();
7632 github_repo(dir.path());
7633 let svc = WorktreesService::new();
7634 svc.handle(
7635 "register",
7636 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7637 )
7638 .await
7639 .unwrap();
7640 svc.registry.set_polling("rust-works", "omni-dev", true);
7643 let mut badges = HashMap::new();
7645 badges.insert(
7646 PrTarget {
7647 owner: "rust-works".into(),
7648 name: "omni-dev".into(),
7649 branch: "other".into(),
7650 },
7651 pr(pending_badge(1, "irrelevant")),
7652 );
7653 svc.pr_cache.replace(badges);
7654 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7655 assert!(wt.get("pr").is_none(), "{wt:?}");
7656 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7658 }
7659
7660 #[tokio::test]
7661 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7662 let dir = tempfile::tempdir().unwrap();
7666 github_repo(dir.path());
7667 let svc = WorktreesService::new();
7668 svc.handle(
7669 "register",
7670 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7671 )
7672 .await
7673 .unwrap();
7674 svc.registry.set_polling("rust-works", "omni-dev", true);
7677
7678 let mut resolutions = HashMap::new();
7679 resolutions.insert(
7680 PrTarget {
7681 owner: "rust-works".into(),
7682 name: "omni-dev".into(),
7683 branch: "main".into(),
7684 },
7685 PrResolution::NoPr,
7686 );
7687 assert!(svc.pr_cache.replace(resolutions));
7688
7689 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7690 assert_eq!(wt["pr_none"], json!(true));
7691 assert!(wt.get("pr").is_none(), "{wt:?}");
7693 }
7694
7695 #[tokio::test]
7696 async fn a_commit_does_not_drop_a_negative_resolution() {
7697 let dir = tempfile::tempdir().unwrap();
7702 let repo = github_repo(dir.path());
7703 let first = repo.head().unwrap().target().unwrap();
7704
7705 let svc = WorktreesService::new();
7706 svc.handle(
7707 "register",
7708 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7709 )
7710 .await
7711 .unwrap();
7712 svc.registry.set_polling("rust-works", "omni-dev", true);
7715
7716 let mut resolutions = HashMap::new();
7717 resolutions.insert(
7718 PrTarget {
7719 owner: "rust-works".into(),
7720 name: "omni-dev".into(),
7721 branch: "main".into(),
7722 },
7723 PrResolution::NoPr,
7724 );
7725 svc.pr_cache.replace(resolutions);
7726
7727 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7728 assert_eq!(wt["pr_none"], json!(true));
7729
7730 let head = repo.find_commit(first).unwrap();
7732 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7733
7734 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7735 assert_eq!(
7736 wt["pr_none"],
7737 json!(true),
7738 "a local commit must not drop the negative"
7739 );
7740 }
7741
7742 #[tokio::test]
7743 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7744 let bin_dir = tempfile::tempdir().unwrap();
7748 let marker = bin_dir.path().join("spawned");
7749 let fake = bin_dir.path().join("fake-gh");
7750 std::fs::write(
7751 &fake,
7752 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7753 )
7754 .unwrap();
7755 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7756 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7757 std::fs::set_permissions(&fake, perms).unwrap();
7758
7759 let svc = WorktreesService::new();
7760 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7761 tokio::time::sleep(Duration::from_millis(200)).await;
7762 svc.shutdown().await;
7763 assert!(
7764 !marker.exists(),
7765 "the poller must not spawn gh with no windows registered"
7766 );
7767 }
7768
7769 #[tokio::test]
7770 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7771 let dir = tempfile::tempdir().unwrap();
7774 let repo = github_repo(dir.path());
7775 let head = repo.head().unwrap().target().unwrap().to_string();
7776 let bin_dir = tempfile::tempdir().unwrap();
7777 let fake = bin_dir.path().join("fake-gh");
7778 std::fs::write(
7780 &fake,
7781 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7782 )
7783 .unwrap();
7784 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7785 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7786 std::fs::set_permissions(&fake, perms).unwrap();
7787
7788 let svc = WorktreesService::new();
7789 svc.handle(
7790 "register",
7791 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7792 )
7793 .await
7794 .unwrap();
7795 svc.registry.set_polling("rust-works", "omni-dev", true);
7798 let mut seeded = HashMap::new();
7800 seeded.insert(
7801 PrTarget {
7802 owner: "rust-works".into(),
7803 name: "omni-dev".into(),
7804 branch: "main".into(),
7805 },
7806 pr(pending_badge(7, &head)),
7807 );
7808 svc.pr_cache.replace(seeded);
7809
7810 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7811 tokio::time::sleep(Duration::from_millis(200)).await;
7812
7813 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7816 assert_eq!(wt["pr"]["number"], json!(7));
7817 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7818 svc.shutdown().await;
7819 }
7820
7821 #[tokio::test]
7822 #[allow(clippy::await_holding_lock)]
7829 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7830 let dir = tempfile::tempdir().unwrap();
7836 github_repo(dir.path());
7837 let bin_dir = tempfile::tempdir().unwrap();
7838 let (fake, _shim) = fake_gh(
7839 bin_dir.path(),
7840 r#"{"data":{"r0":{"b0":{
7841 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7842 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7843 ]}}},
7844 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7845 }}}}"#,
7846 );
7847
7848 let svc = WorktreesService::new();
7849 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7851 tokio::time::sleep(Duration::from_millis(150)).await;
7852
7853 svc.handle(
7855 "register",
7856 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7857 )
7858 .await
7859 .unwrap();
7860 svc.registry.set_polling("rust-works", "omni-dev", true);
7863
7864 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7868 loop {
7869 if let Some(PrResolution::Pr(badge)) =
7870 svc.pr_cache.get("rust-works", "omni-dev", "main")
7871 {
7872 return badge;
7873 }
7874 tokio::time::sleep(Duration::from_millis(25)).await;
7875 }
7876 })
7877 .await
7878 .expect("a window opening must wake the poller out of its idle backoff");
7879 assert_eq!(badge.number, 99);
7880 svc.shutdown().await;
7881 }
7882
7883 #[tokio::test]
7884 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7885 let dir = tempfile::tempdir().unwrap();
7892 let repo = github_repo(dir.path());
7893 let first = repo.head().unwrap().target().unwrap();
7894
7895 let svc = WorktreesService::new();
7896 svc.handle(
7897 "register",
7898 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7899 )
7900 .await
7901 .unwrap();
7902 svc.registry.set_polling("rust-works", "omni-dev", true);
7905
7906 let mut badges = HashMap::new();
7908 badges.insert(
7909 PrTarget {
7910 owner: "rust-works".into(),
7911 name: "omni-dev".into(),
7912 branch: "main".into(),
7913 },
7914 pr(PrBadge {
7915 number: 1337,
7916 is_draft: false,
7917 checks: PrCheckState::Success,
7918 url: "u".into(),
7919 head_oid: first.to_string(),
7920 }),
7921 );
7922 svc.pr_cache.replace(badges);
7923
7924 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7925 assert_eq!(
7926 wt["pr"]["checks"],
7927 json!("success"),
7928 "green for its own commit"
7929 );
7930
7931 let head = repo.find_commit(first).unwrap();
7933 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7934
7935 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7936 assert_eq!(
7937 wt["pr"]["checks"],
7938 json!("pending"),
7939 "the previous commit's ✓ must not stand after a new commit"
7940 );
7941 assert_eq!(wt["pr"]["number"], json!(1337));
7944 }
7945
7946 #[test]
7947 fn is_stale_for_compares_the_commit_the_verdict_describes() {
7948 let badge = pending_badge(1, "aaa");
7949 assert!(!badge.is_stale_for(Some("aaa")));
7950 assert!(badge.is_stale_for(Some("bbb")));
7951 assert!(!badge.is_stale_for(None));
7953 }
7954
7955 #[test]
7956 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7957 let snap = |sha: &str| {
7962 json!({"repos":[{
7963 "main_repo":"omni-dev",
7964 "github":{"owner":"rust-works","name":"omni-dev"},
7965 "root":"/r",
7966 "polling_enabled":true,
7967 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7968 }]})
7969 };
7970 let before = pr_watch_from_snapshot(&snap("aaa"));
7971 let after = pr_watch_from_snapshot(&snap("bbb"));
7972 assert_eq!(before.len(), 1);
7973 assert_eq!(before[0].target, after[0].target);
7974 assert_eq!(before, after);
7976 assert!(!pr_watch_grew(&before, &after));
7977 }
7978
7979 #[test]
7980 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7981 let snap = |upstream: &str| {
7985 json!({"repos":[{
7986 "main_repo":"omni-dev",
7987 "github":{"owner":"rust-works","name":"omni-dev"},
7988 "root":"/r",
7989 "polling_enabled":true,
7990 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7991 "upstream_sha":upstream,"is_main":true,"open":true}]
7992 }]})
7993 };
7994 let before = pr_watch_from_snapshot(&snap("aaa"));
7995 let after = pr_watch_from_snapshot(&snap("bbb"));
7996 assert_eq!(before.len(), 1);
7998 assert_eq!(before[0].target, after[0].target);
7999 assert_ne!(before, after);
8000 assert!(pr_watch_grew(&before, &after));
8001 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8003 assert!(!pr_watch_grew(
8004 &before,
8005 &pr_watch_from_snapshot(&snap("aaa"))
8006 ));
8007 }
8008
8009 #[test]
8010 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8011 let snap = json!({"repos":[{
8014 "main_repo":"omni-dev",
8015 "github":{"owner":"rust-works","name":"omni-dev"},
8016 "root":"/r",
8017 "polling_enabled":true,
8018 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8019 }]});
8020 let watch = pr_watch_from_snapshot(&snap);
8021 assert_eq!(watch.len(), 1);
8022 assert_eq!(watch[0].upstream_sha, None);
8023 }
8024
8025 #[test]
8026 fn start_pr_poller_is_a_noop_outside_a_runtime() {
8027 let svc = WorktreesService::new();
8028 svc.start_pr_poller();
8029 assert!(svc
8030 .poller
8031 .lock()
8032 .unwrap_or_else(PoisonError::into_inner)
8033 .is_none());
8034 }
8035
8036 #[tokio::test]
8037 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8038 let svc = WorktreesService::new();
8039 svc.start_pr_poller_with(
8040 Duration::from_millis(50),
8041 Duration::from_millis(10),
8042 PathBuf::from("/bin/true"),
8043 );
8044 let token = svc
8045 .poller
8046 .lock()
8047 .unwrap_or_else(PoisonError::into_inner)
8048 .as_ref()
8049 .map(|t| t.token.clone())
8050 .expect("poller started");
8051
8052 token.cancel();
8055 svc.start_pr_poller_with(
8056 Duration::from_millis(50),
8057 Duration::from_millis(10),
8058 PathBuf::from("/bin/true"),
8059 );
8060 assert!(svc
8061 .poller
8062 .lock()
8063 .unwrap_or_else(PoisonError::into_inner)
8064 .as_ref()
8065 .is_some_and(|t| t.token.is_cancelled()));
8066
8067 svc.shutdown().await;
8068 assert!(svc
8069 .poller
8070 .lock()
8071 .unwrap_or_else(PoisonError::into_inner)
8072 .is_none());
8073 }
8074
8075 fn rl_resource(used: u64) -> RateLimitResource {
8079 RateLimitResource {
8080 used,
8081 limit: 100,
8082 remaining: 100 - used,
8083 percent: used as f64,
8084 reset: 0,
8085 }
8086 }
8087
8088 #[test]
8089 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8090 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8091 graphql: Some(rl_resource(graphql)),
8092 core: Some(rl_resource(core)),
8093 search: None,
8094 };
8095 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8097 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8099 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8101 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8103 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8105 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8107 }
8108
8109 #[test]
8110 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8111 let svc = WorktreesService::new();
8112 svc.start_rate_limit_poller();
8113 assert!(svc
8114 .rate_limit_poller
8115 .lock()
8116 .unwrap_or_else(PoisonError::into_inner)
8117 .is_none());
8118 }
8119
8120 #[tokio::test]
8121 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8122 let svc = WorktreesService::new();
8123 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8124 let token = svc
8125 .rate_limit_poller
8126 .lock()
8127 .unwrap_or_else(PoisonError::into_inner)
8128 .as_ref()
8129 .map(|t| t.token.clone())
8130 .expect("poller started");
8131
8132 token.cancel();
8135 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8136 assert!(svc
8137 .rate_limit_poller
8138 .lock()
8139 .unwrap_or_else(PoisonError::into_inner)
8140 .as_ref()
8141 .is_some_and(|t| t.token.is_cancelled()));
8142
8143 svc.shutdown().await;
8144 assert!(svc
8145 .rate_limit_poller
8146 .lock()
8147 .unwrap_or_else(PoisonError::into_inner)
8148 .is_none());
8149 }
8150
8151 #[tokio::test]
8152 #[allow(clippy::await_holding_lock)]
8154 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8155 let bin_dir = tempfile::tempdir().unwrap();
8156 let (fake, _shim) = fake_gh(
8157 bin_dir.path(),
8158 r#"{"resources":{
8159 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8160 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8161 }}"#,
8162 );
8163 let svc = WorktreesService::new();
8164 svc.registry.set_polling("rust-works", "omni-dev", true);
8167 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8168
8169 let snap = tokio::time::timeout(Duration::from_secs(30), async {
8172 loop {
8173 if let Some(snap) = svc.rate_limit_cache.get() {
8174 return snap;
8175 }
8176 tokio::time::sleep(Duration::from_millis(25)).await;
8177 }
8178 })
8179 .await
8180 .expect("poller should populate the cache through the fake gh");
8181 assert_eq!(snap.graphql.unwrap().used, 4100);
8182 assert_eq!(snap.core.unwrap().used, 27);
8183
8184 assert!(svc.rate_limit_cache().get().is_some());
8186
8187 svc.shutdown().await;
8188 assert!(svc
8189 .rate_limit_poller
8190 .lock()
8191 .unwrap_or_else(PoisonError::into_inner)
8192 .is_none());
8193 }
8194
8195 #[tokio::test]
8196 #[allow(clippy::await_holding_lock)]
8198 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8199 let bin_dir = tempfile::tempdir().unwrap();
8202 let (fake, _shim, counter) = counting_fake_gh(
8203 bin_dir.path(),
8204 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8205 );
8206 let svc = WorktreesService::new();
8207 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8208
8209 tokio::time::sleep(Duration::from_millis(300)).await;
8211 assert_eq!(
8212 gh_spawn_count(&counter),
8213 0,
8214 "idle daemon must not poll (#1389, fix 8b)"
8215 );
8216 assert!(svc.rate_limit_cache.get().is_none());
8217
8218 svc.registry.set_polling("rust-works", "omni-dev", true);
8220 tokio::time::timeout(Duration::from_secs(30), async {
8221 loop {
8222 if svc.rate_limit_cache.get().is_some() {
8223 return;
8224 }
8225 tokio::time::sleep(Duration::from_millis(25)).await;
8226 }
8227 })
8228 .await
8229 .expect("an active lease should resume polling");
8230 assert!(gh_spawn_count(&counter) >= 1);
8231 svc.shutdown().await;
8232 }
8233
8234 #[tokio::test]
8235 async fn rate_limit_poller_survives_a_failing_gh() {
8236 let svc = WorktreesService::new();
8240 svc.registry.set_polling("rust-works", "omni-dev", true);
8241 svc.start_rate_limit_poller_with(
8242 Duration::from_millis(20),
8243 PathBuf::from("/no/such/gh/xyzzy"),
8244 );
8245 tokio::time::sleep(Duration::from_millis(150)).await;
8247 assert!(svc.rate_limit_cache.get().is_none());
8248 assert!(
8249 svc.rate_limit_poller
8250 .lock()
8251 .unwrap_or_else(PoisonError::into_inner)
8252 .is_some(),
8253 "the loop must survive a failing gh, not panic out"
8254 );
8255 svc.shutdown().await;
8256 }
8257
8258 #[test]
8259 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8260 let svc = WorktreesService::new();
8261 let items = svc.menu().items;
8263 assert!(
8264 !items
8265 .iter()
8266 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8267 "no github line before the first poll"
8268 );
8269
8270 svc.rate_limit_cache.replace(RateLimitSnapshot {
8272 graphql: Some(rl_resource(82)),
8273 core: Some(rl_resource(3)),
8274 search: None,
8275 });
8276 let items = svc.menu().items;
8277 assert!(
8278 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8279 "expected the github line first, got {items:?}"
8280 );
8281 assert!(
8282 matches!(items.get(1), Some(MenuItem::Separator)),
8283 "expected a separator after the github line"
8284 );
8285 }
8286
8287 #[tokio::test]
8288 #[allow(clippy::await_holding_lock)]
8290 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8291 let dir = tempfile::tempdir().unwrap();
8292 github_repo(dir.path());
8293 let bin_dir = tempfile::tempdir().unwrap();
8294 let (fake, _shim) = fake_gh(
8297 bin_dir.path(),
8298 r#"{"data":{"r0":{"b0":{
8299 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8300 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8301 ]}}},
8302 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8303 }}}}"#,
8304 );
8305 let svc = WorktreesService::new();
8306 svc.handle(
8307 "register",
8308 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8309 )
8310 .await
8311 .unwrap();
8312 svc.registry.set_polling("rust-works", "omni-dev", true);
8315 svc.start_pr_poller_with(
8316 Duration::from_millis(50),
8317 Duration::from_millis(10),
8318 fake.clone(),
8319 );
8320
8321 let badge = tokio::time::timeout(Duration::from_secs(30), async {
8325 loop {
8326 if let Some(PrResolution::Pr(badge)) =
8327 svc.pr_cache.get("rust-works", "omni-dev", "main")
8328 {
8329 return badge;
8330 }
8331 tokio::time::sleep(Duration::from_millis(25)).await;
8332 }
8333 })
8334 .await
8335 .expect("poller should resolve a badge through the fake gh");
8336 assert_eq!(badge.number, 1337);
8337 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8338
8339 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8341 assert_eq!(wt["pr"]["number"], json!(1337));
8342
8343 svc.shutdown().await;
8345 let generation = svc.registry.change_generation();
8346 tokio::time::sleep(Duration::from_millis(120)).await;
8347 assert_eq!(
8348 svc.registry.change_generation(),
8349 generation,
8350 "no bumps after shutdown"
8351 );
8352 }
8353
8354 #[tokio::test]
8355 #[allow(clippy::await_holding_lock)]
8357 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8358 let dir = tempfile::tempdir().unwrap();
8362 github_repo(dir.path());
8363 let bin_dir = tempfile::tempdir().unwrap();
8364 let (fake, _shim) = fake_gh(
8365 bin_dir.path(),
8366 r#"{"data":{
8367 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8368 "resetAt":"2026-07-21T16:00:00Z"},
8369 "r0":{"b0":{
8370 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8371 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8372 ]}}},
8373 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8374 }}
8375 }}"#,
8376 );
8377 let svc = WorktreesService::new();
8378 svc.handle(
8379 "register",
8380 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8381 )
8382 .await
8383 .unwrap();
8384 svc.registry.set_polling("rust-works", "omni-dev", true);
8385 svc.start_pr_poller_with(
8388 Duration::from_millis(50),
8389 Duration::from_millis(10),
8390 fake.clone(),
8391 );
8392
8393 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8394 loop {
8395 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8396 return g;
8397 }
8398 tokio::time::sleep(Duration::from_millis(25)).await;
8399 }
8400 })
8401 .await
8402 .expect("the PR poll should fold its budget into the cache");
8403 assert_eq!(graphql.used, 123);
8404 assert_eq!(graphql.limit, 5000);
8405 assert_eq!(graphql.remaining, 4877);
8406 svc.shutdown().await;
8407 }
8408
8409 #[tokio::test]
8410 #[allow(clippy::await_holding_lock)]
8412 async fn pr_poll_counts_every_gh_call_exactly_once() {
8413 let dir = tempfile::tempdir().unwrap();
8420 github_repo(dir.path());
8421 let bin_dir = tempfile::tempdir().unwrap();
8422 let (fake, _shim, counter) = counting_fake_gh(
8423 bin_dir.path(),
8424 r#"{"data":{"r0":{"b0":{
8425 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8426 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8427 ]}}},
8428 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8429 }}}}"#,
8430 );
8431 let log = bin_dir.path().join("log.jsonl");
8432 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8433
8434 let svc = WorktreesService::new();
8435 svc.handle(
8436 "register",
8437 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8438 )
8439 .await
8440 .unwrap();
8441 svc.registry.set_polling("rust-works", "omni-dev", true);
8442 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8443
8444 tokio::time::timeout(Duration::from_secs(30), async {
8446 loop {
8447 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8448 return;
8449 }
8450 tokio::time::sleep(Duration::from_millis(25)).await;
8451 }
8452 })
8453 .await
8454 .expect("poller should fetch through the fake gh");
8455
8456 svc.shutdown().await;
8458 let spawns = gh_spawn_count(&counter);
8459 let counted = counted_gh_records(&log);
8460 std::env::remove_var("OMNI_DEV_LOG_FILE");
8461 assert!(
8462 spawns >= 1,
8463 "the poll should have spent at least one gh call"
8464 );
8465 assert_eq!(
8466 counted, spawns,
8467 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8468 );
8469 }
8470
8471 #[tokio::test]
8472 #[allow(clippy::await_holding_lock)]
8474 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8475 let dir_a = tempfile::tempdir().unwrap();
8480 let dir_b = tempfile::tempdir().unwrap();
8481 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();
8484 let (fake, _shim, counter) = counting_fake_gh(
8486 bin_dir.path(),
8487 r#"{"data":{
8488 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8489 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8490 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8491 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8492 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8493 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8494 }}"#,
8495 );
8496 let svc = WorktreesService::new();
8497 svc.registry.set_polling("rust-works", "omni-dev", true);
8500 svc.registry.set_polling("rust-works", "other-repo", true);
8501 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8505 svc.handle(
8507 "register",
8508 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8509 )
8510 .await
8511 .unwrap();
8512 tokio::time::sleep(Duration::from_millis(50)).await;
8516 svc.handle(
8517 "register",
8518 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8519 )
8520 .await
8521 .unwrap();
8522
8523 tokio::time::timeout(Duration::from_secs(30), async {
8526 loop {
8527 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8528 let b = svc
8529 .pr_cache
8530 .get("rust-works", "other-repo", "main")
8531 .is_some();
8532 if a && b {
8533 return;
8534 }
8535 tokio::time::sleep(Duration::from_millis(25)).await;
8536 }
8537 })
8538 .await
8539 .expect("the debounced fetch should resolve both repos");
8540
8541 svc.shutdown().await;
8542 assert_eq!(
8543 gh_spawn_count(&counter),
8544 1,
8545 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8546 );
8547 }
8548
8549 #[tokio::test]
8550 #[allow(clippy::await_holding_lock)]
8552 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8553 let dir = tempfile::tempdir().unwrap();
8558 github_repo(dir.path());
8559 let bin_dir = tempfile::tempdir().unwrap();
8560 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8561 let svc = WorktreesService::new();
8562 svc.registry.set_polling("rust-works", "omni-dev", true);
8563 svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8567 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8568 svc.handle("register", register.clone()).await.unwrap();
8569 let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8579 loop {
8580 tokio::time::sleep(Duration::from_millis(25)).await;
8581 svc.handle("register", register.clone()).await.unwrap();
8582 if gh_spawn_count(&counter) >= 1 {
8583 return;
8584 }
8585 }
8586 })
8587 .await;
8588 svc.shutdown().await;
8589 forced_mid_drip.expect(
8590 "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8591 );
8592 }
8593
8594 #[tokio::test]
8595 #[allow(clippy::await_holding_lock)]
8597 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8598 let dir = tempfile::tempdir().unwrap();
8602 let repo = github_repo(dir.path());
8603 let head = repo.head().unwrap().target().unwrap().to_string();
8604 let bin_dir = tempfile::tempdir().unwrap();
8605 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8608
8609 let cache_path = bin_dir.path().join("pr-cache.json");
8613 let target = PrTarget {
8614 owner: "rust-works".into(),
8615 name: "omni-dev".into(),
8616 branch: "main".into(),
8617 };
8618 let prefs = pr_cache_prefs_from(
8619 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8620 &[watch("main", None)],
8621 Utc::now(),
8622 );
8623 write_pr_cache(&cache_path, &prefs).unwrap();
8624
8625 let svc = WorktreesService::new();
8626 svc.load_pr_cache(cache_path);
8627 svc.registry.set_polling("rust-works", "omni-dev", true);
8628 svc.handle(
8629 "register",
8630 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8631 )
8632 .await
8633 .unwrap();
8634 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8637
8638 let number = tokio::time::timeout(Duration::from_secs(30), async {
8640 loop {
8641 let tree = svc.handle("tree", Value::Null).await.unwrap();
8642 if let Some(n) = repos_of(&tree)
8643 .first()
8644 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8645 {
8646 return n;
8647 }
8648 tokio::time::sleep(Duration::from_millis(25)).await;
8649 }
8650 })
8651 .await
8652 .expect("the restored badge should render from the warm cache");
8653 assert_eq!(number, 1337);
8654
8655 tokio::time::sleep(Duration::from_millis(300)).await;
8657 svc.shutdown().await;
8658 assert_eq!(
8659 gh_spawn_count(&counter),
8660 0,
8661 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8662 );
8663 }
8664
8665 #[tokio::test]
8666 #[allow(clippy::await_holding_lock)]
8668 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8669 let dir = tempfile::tempdir().unwrap();
8674 github_repo(dir.path());
8675 let bin_dir = tempfile::tempdir().unwrap();
8676 let (fake, _shim) = fake_gh(
8677 bin_dir.path(),
8678 r#"{"data":{"r0":{"b0":{
8679 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8680 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8681 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8682 }}}}"#,
8683 );
8684 let svc = WorktreesService::new();
8685 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8688 svc.load_pr_cache(cache_path.clone());
8689 svc.registry.set_polling("rust-works", "omni-dev", true);
8690 svc.handle(
8691 "register",
8692 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8693 )
8694 .await
8695 .unwrap();
8696 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8697
8698 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8701 loop {
8702 if let Ok(bytes) = std::fs::read(&cache_path) {
8703 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8704 if !prefs.entries.is_empty() {
8705 return prefs;
8706 }
8707 }
8708 }
8709 tokio::time::sleep(Duration::from_millis(25)).await;
8710 }
8711 })
8712 .await
8713 .expect("a successful resolve should persist the cache file");
8714 svc.shutdown().await;
8715
8716 assert_eq!(prefs.entries[0].target.branch, "main");
8717 assert!(
8718 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8719 "{:?}",
8720 prefs.entries[0].resolution
8721 );
8722 assert_eq!(
8723 prefs.watched,
8724 vec![PersistedWatch {
8725 target: prefs.entries[0].target.clone(),
8726 upstream_sha: None
8727 }]
8728 );
8729 assert!(
8730 prefs.polled_at.is_some(),
8731 "the poll time is what ages the next warm start"
8732 );
8733 }
8734
8735 #[tokio::test]
8736 #[allow(clippy::await_holding_lock)]
8738 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8739 let bin_dir = tempfile::tempdir().unwrap();
8743 let (fake, _shim, counter) = counting_fake_gh(
8744 bin_dir.path(),
8745 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8746 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8747 );
8748 let svc = WorktreesService::new();
8749
8750 let prs = svc
8751 .open_prs_with("rust-works", "omni-dev", fake.clone())
8752 .await
8753 .expect("gh pr list should resolve");
8754 assert_eq!(prs.len(), 1);
8755 assert_eq!(prs[0]["number"], json!(42));
8756 assert_eq!(prs[0]["url"], json!("http://x/42"));
8757 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8758
8759 let again = svc
8761 .open_prs_with("rust-works", "omni-dev", fake.clone())
8762 .await
8763 .expect("cache hit should resolve");
8764 assert_eq!(again, prs);
8765 assert_eq!(
8766 gh_spawn_count(&counter),
8767 1,
8768 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8769 );
8770
8771 let reply = svc
8773 .handle(
8774 "open-prs",
8775 json!({ "owner": "rust-works", "name": "omni-dev" }),
8776 )
8777 .await
8778 .expect("open-prs op should route");
8779 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8780 assert!(svc
8781 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
8782 .await
8783 .is_err());
8784 }
8785
8786 #[test]
8787 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8788 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8793 assert!(
8794 err.to_string().contains("is the GitHub CLI installed"),
8795 "{err:#}"
8796 );
8797
8798 let bin_dir = tempfile::tempdir().unwrap();
8799 let _guard = shim_lock();
8800 let failing = bin_dir.path().join("fake-gh-fails");
8801 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8802 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8803 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8804 assert!(err.to_string().contains("boom"), "{err:#}");
8805
8806 let object = bin_dir.path().join("fake-gh-object");
8807 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8808 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8809 assert!(
8810 err.to_string().contains("did not return a JSON array"),
8811 "{err:#}"
8812 );
8813 }
8814
8815 #[tokio::test]
8816 #[allow(clippy::await_holding_lock)]
8818 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8819 let dir = tempfile::tempdir().unwrap();
8825 github_repo(dir.path());
8826 let bin_dir = tempfile::tempdir().unwrap();
8827 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8828
8829 let svc = WorktreesService::new();
8830 *svc.pr_warm_start
8834 .lock()
8835 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8836 watched: vec![],
8837 polled_at: Utc::now(),
8838 });
8839 svc.rate_limit_cache.replace(RateLimitSnapshot {
8841 graphql: Some(rl_resource(90)),
8842 core: Some(rl_resource(3)),
8843 search: None,
8844 });
8845 svc.registry.set_polling("rust-works", "omni-dev", true);
8846 svc.handle(
8847 "register",
8848 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8849 )
8850 .await
8851 .unwrap();
8852 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8855
8856 tokio::time::sleep(Duration::from_millis(300)).await;
8858 svc.shutdown().await;
8859 assert_eq!(
8860 gh_spawn_count(&counter),
8861 0,
8862 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8863 );
8864 }
8865
8866 #[tokio::test]
8867 #[allow(clippy::await_holding_lock)]
8869 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8870 let dir = tempfile::tempdir().unwrap();
8873 github_repo(dir.path());
8874 let bin_dir = tempfile::tempdir().unwrap();
8875 let (fake, _shim) = fake_gh(
8876 bin_dir.path(),
8877 r#"{"data":{"r0":{"b0":{
8878 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8879 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8880 ]}}},
8881 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8882 }}}}"#,
8883 );
8884 let svc = WorktreesService::new();
8885 svc.handle(
8886 "register",
8887 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8888 )
8889 .await
8890 .unwrap();
8891 svc.registry.set_polling("rust-works", "omni-dev", true);
8894 svc.start_pr_poller_with(
8895 Duration::from_millis(50),
8896 Duration::from_millis(10),
8897 fake.clone(),
8898 );
8899
8900 tokio::time::timeout(Duration::from_secs(30), async {
8901 loop {
8902 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8903 return;
8904 }
8905 tokio::time::sleep(Duration::from_millis(25)).await;
8906 }
8907 })
8908 .await
8909 .expect("poller should resolve a badge through the fake gh");
8910 let settled = svc.registry.change_generation();
8913 tokio::time::sleep(Duration::from_millis(150)).await;
8914 assert_eq!(
8915 svc.registry.change_generation(),
8916 settled,
8917 "an unchanged poll must not bump the change-notify"
8918 );
8919 svc.shutdown().await;
8920 }
8921
8922 #[tokio::test]
8923 #[allow(clippy::await_holding_lock)]
8925 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8926 let dir = tempfile::tempdir().unwrap();
8931 github_repo(dir.path());
8932 let bin_dir = tempfile::tempdir().unwrap();
8933 let (fake, _shim) = fake_gh(
8934 bin_dir.path(),
8935 r#"{"data":{"r0":{"b0":{
8936 "target":{"oid":"abc","statusCheckRollup":null},
8937 "associatedPullRequests":{"nodes":[]}
8938 }}}}"#,
8939 );
8940 let svc = WorktreesService::new();
8941 svc.handle(
8942 "register",
8943 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8944 )
8945 .await
8946 .unwrap();
8947 svc.registry.set_polling("rust-works", "omni-dev", true);
8950 svc.start_pr_poller_with(
8951 Duration::from_millis(50),
8952 Duration::from_millis(10),
8953 fake.clone(),
8954 );
8955
8956 tokio::time::timeout(Duration::from_secs(30), async {
8957 loop {
8958 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8959 return;
8960 }
8961 tokio::time::sleep(Duration::from_millis(25)).await;
8962 }
8963 })
8964 .await
8965 .expect("poller should resolve the negative through the fake gh");
8966
8967 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8969 assert_eq!(wt["pr_none"], json!(true));
8970 assert!(wt.get("pr").is_none(), "{wt:?}");
8971
8972 let settled = svc.registry.change_generation();
8974 tokio::time::sleep(Duration::from_millis(150)).await;
8975 assert_eq!(
8976 svc.registry.change_generation(),
8977 settled,
8978 "an unchanged negative must not bump the change-notify"
8979 );
8980 svc.shutdown().await;
8981 }
8982
8983 #[test]
8984 fn sync_indicator_formats_only_with_upstream() {
8985 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8986 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8987 assert_eq!(sync_indicator(None, None), None);
8988 assert_eq!(sync_indicator(Some(1), None), None);
8990 }
8991
8992 #[tokio::test]
8993 async fn list_enriches_entries_with_git_status() {
8994 let dir = tempfile::tempdir().unwrap();
8995 let repo = init_repo(dir.path());
8996 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8997 repo.set_head("refs/heads/main").unwrap();
8998
8999 let svc = WorktreesService::new();
9000 svc.handle(
9001 "register",
9002 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9003 )
9004 .await
9005 .unwrap();
9006 let payload = svc.handle("list", Value::Null).await.unwrap();
9007 let windows = windows_of(&payload);
9008 assert_eq!(windows.len(), 1);
9009 assert_eq!(
9010 windows[0].get("branch").and_then(Value::as_str),
9011 Some("main")
9012 );
9013 assert!(windows[0].get("ahead").is_none());
9015 assert!(windows[0].get("behind").is_none());
9016 assert_eq!(
9018 windows[0].get("main_repo").and_then(Value::as_str),
9019 dir.path().file_name().and_then(|n| n.to_str())
9020 );
9021
9022 let plain = tempfile::tempdir().unwrap();
9024 svc.handle(
9025 "register",
9026 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9027 )
9028 .await
9029 .unwrap();
9030 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9031 let w2 = windows
9032 .iter()
9033 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9034 .unwrap();
9035 assert!(w2.get("branch").is_none());
9036 assert!(w2.get("main_repo").is_none());
9037 }
9038
9039 #[test]
9040 fn window_label_prefers_git_branch_over_title() {
9041 let dir = tempfile::tempdir().unwrap();
9042 let repo = init_repo(dir.path());
9043 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9044 repo.set_head("refs/heads/main").unwrap();
9045 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9046 let entry = WindowEntry {
9047 key: "k".to_string(),
9048 folders: vec![dir.path().to_path_buf()],
9049 repo: Some("companion-repo".to_string()),
9052 title: Some("ignored title".to_string()),
9053 pid: None,
9054 last_seen: Utc::now(),
9055 };
9056 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9058 }
9059
9060 #[tokio::test]
9061 async fn list_includes_ahead_behind_for_tracking_branch() {
9062 let dir = tempfile::tempdir().unwrap();
9063 let _repo = diverging_repo(dir.path());
9064
9065 let svc = WorktreesService::new();
9066 svc.handle(
9067 "register",
9068 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9069 )
9070 .await
9071 .unwrap();
9072 let payload = svc.handle("list", Value::Null).await.unwrap();
9073 let windows = windows_of(&payload);
9074 assert_eq!(
9076 windows[0].get("branch").and_then(Value::as_str),
9077 Some("main")
9078 );
9079 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9080 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9081 }
9082
9083 #[test]
9084 fn window_label_includes_sync_for_tracking_branch() {
9085 let dir = tempfile::tempdir().unwrap();
9086 let _repo = diverging_repo(dir.path());
9087 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9088 let entry = WindowEntry {
9089 key: "k".to_string(),
9090 folders: vec![dir.path().to_path_buf()],
9091 repo: Some("companion-repo".to_string()),
9092 title: None,
9093 pid: None,
9094 last_seen: Utc::now(),
9095 };
9096 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9098 }
9099
9100 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9104 let commit = repo.find_commit(base).unwrap();
9105 repo.branch(branch, &commit, false).unwrap();
9106 let reference = repo
9107 .find_reference(&format!("refs/heads/{branch}"))
9108 .unwrap();
9109 let mut opts = git2::WorktreeAddOptions::new();
9110 opts.reference(Some(&reference));
9111 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9112 }
9113
9114 #[test]
9115 fn git_status_marks_linked_worktree_and_names_parent_repo() {
9116 let main_dir = tempfile::tempdir().unwrap();
9117 let repo = init_repo(main_dir.path());
9118 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9119 repo.set_head("refs/heads/main").unwrap();
9120
9121 let wt_parent = tempfile::tempdir().unwrap();
9124 let wt_path = wt_parent.path().join("feature-wt");
9125 add_worktree(&repo, a, &wt_path, "feature");
9126
9127 let status = git_status(&wt_path);
9128 assert!(status.is_worktree);
9129 assert_eq!(status.branch.as_deref(), Some("feature"));
9130 assert_eq!(
9132 status.main_repo.as_deref(),
9133 main_dir.path().file_name().and_then(|n| n.to_str())
9134 );
9135
9136 let main_status = git_status(main_dir.path());
9138 assert!(!main_status.is_worktree);
9139 assert_eq!(main_status.main_repo, status.main_repo);
9140 }
9141
9142 #[test]
9143 fn window_label_marks_worktree_with_fork_glyph() {
9144 let main_dir = tempfile::tempdir().unwrap();
9145 let repo = init_repo(main_dir.path());
9146 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9147 repo.set_head("refs/heads/main").unwrap();
9148 let wt_parent = tempfile::tempdir().unwrap();
9149 let wt_path = wt_parent.path().join("feature-wt");
9150 add_worktree(&repo, a, &wt_path, "feature");
9151
9152 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9153 let entry = WindowEntry {
9154 key: "k".to_string(),
9155 folders: vec![wt_path],
9156 repo: Some("feature-wt".to_string()),
9157 title: None,
9158 pid: None,
9159 last_seen: Utc::now(),
9160 };
9161 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9164 }
9165
9166 #[test]
9167 fn main_repo_name_derives_from_common_dir() {
9168 assert_eq!(
9170 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9171 Some("omni-dev")
9172 );
9173 assert_eq!(
9175 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9176 Some("omni-dev")
9177 );
9178 assert_eq!(
9180 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9181 Some("omni-dev")
9182 );
9183 assert_eq!(main_repo_name(Path::new("/.git")), None);
9185 }
9186
9187 fn repos_of(payload: &Value) -> Vec<Value> {
9192 payload
9193 .get("repos")
9194 .and_then(Value::as_array)
9195 .expect("repos array")
9196 .clone()
9197 }
9198
9199 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9200 Some(GithubIdentity {
9201 owner: owner.to_string(),
9202 name: name.to_string(),
9203 })
9204 }
9205
9206 #[test]
9207 fn github_identity_parses_supported_forms() {
9208 assert_eq!(
9210 github_identity("https://github.com/rust-works/omni-dev.git"),
9211 github("rust-works", "omni-dev")
9212 );
9213 assert_eq!(
9214 github_identity("https://github.com/rust-works/omni-dev"),
9215 github("rust-works", "omni-dev")
9216 );
9217 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9218 assert_eq!(
9220 github_identity("git@github.com:rust-works/omni-dev.git"),
9221 github("rust-works", "omni-dev")
9222 );
9223 assert_eq!(
9224 github_identity("ssh://git@github.com/o/r.git"),
9225 github("o", "r")
9226 );
9227 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9228 assert_eq!(
9230 github_identity(" https://github.com/o/r/ "),
9231 github("o", "r")
9232 );
9233 }
9234
9235 #[test]
9236 fn github_identity_rejects_non_github_and_malformed() {
9237 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9239 assert_eq!(github_identity("git@example.com:o/r.git"), None);
9240 assert_eq!(github_identity("https://github.com/onlyowner"), None);
9242 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9243 assert_eq!(github_identity("https://github.com/"), None);
9244 assert_eq!(github_identity("not a url"), None);
9246 }
9247
9248 #[test]
9249 fn remote_github_identity_reads_origin_then_falls_back() {
9250 let dir = tempfile::tempdir().unwrap();
9251 let repo = init_repo(dir.path());
9252 assert_eq!(remote_github_identity(&repo), None);
9254 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9256 assert_eq!(remote_github_identity(&repo), None);
9257 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9259 .unwrap();
9260 assert_eq!(
9261 remote_github_identity(&repo),
9262 github("rust-works", "omni-dev")
9263 );
9264
9265 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9268 .unwrap();
9269 repo.remote("upstream", "https://github.com/other/proj.git")
9270 .unwrap();
9271 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9272 }
9273
9274 #[tokio::test]
9275 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9276 let svc = WorktreesService::new();
9277 assert_eq!(
9279 svc.handle("tree", Value::Null).await.unwrap(),
9280 json!({ "repos": [], "show_closed": true })
9281 );
9282 let plain = tempfile::tempdir().unwrap();
9284 svc.handle(
9285 "register",
9286 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9287 )
9288 .await
9289 .unwrap();
9290 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9291 }
9292
9293 #[tokio::test]
9294 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9295 let main_dir = tempfile::tempdir().unwrap();
9296 let repo = init_repo(main_dir.path());
9297 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9298 repo.set_head("refs/heads/main").unwrap();
9299 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9301 .unwrap();
9302
9303 let wt_parent = tempfile::tempdir().unwrap();
9306 let wt_path = wt_parent.path().join("feature-wt");
9307 add_worktree(&repo, a, &wt_path, "feature");
9308
9309 let svc = WorktreesService::new();
9310 svc.handle(
9313 "register",
9314 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9315 )
9316 .await
9317 .unwrap();
9318 svc.handle(
9319 "register",
9320 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9321 )
9322 .await
9323 .unwrap();
9324
9325 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9326 assert_eq!(
9327 repos.len(),
9328 1,
9329 "two worktrees of one repo dedupe: {repos:?}"
9330 );
9331 let repo0 = &repos[0];
9332 assert_eq!(
9334 repo0.get("main_repo").and_then(Value::as_str),
9335 main_dir.path().file_name().and_then(|n| n.to_str())
9336 );
9337 assert_eq!(
9338 repo0.pointer("/github/owner").and_then(Value::as_str),
9339 Some("rust-works")
9340 );
9341 assert_eq!(
9342 repo0.pointer("/github/name").and_then(Value::as_str),
9343 Some("omni-dev")
9344 );
9345 assert!(repo0.get("root").and_then(Value::as_str).is_some());
9346
9347 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9348 assert_eq!(worktrees.len(), 2);
9349 let main_wt = &worktrees[0];
9351 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9352 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9353 assert_eq!(
9354 main_wt.get("window_key").and_then(Value::as_str),
9355 Some("wm")
9356 );
9357 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9358 let linked = &worktrees[1];
9360 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9361 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9362 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9363 assert_eq!(
9364 linked.get("branch").and_then(Value::as_str),
9365 Some("feature")
9366 );
9367 }
9368
9369 #[tokio::test]
9370 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9371 let main_dir = tempfile::tempdir().unwrap();
9372 let repo = init_repo(main_dir.path());
9373 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9374 repo.set_head("refs/heads/main").unwrap();
9375 let wt_parent = tempfile::tempdir().unwrap();
9377 let wt_path = wt_parent.path().join("feature-wt");
9378 add_worktree(&repo, a, &wt_path, "feature");
9379
9380 let svc = WorktreesService::new();
9381 svc.handle(
9383 "register",
9384 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9385 )
9386 .await
9387 .unwrap();
9388
9389 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9390 assert_eq!(repos.len(), 1);
9391 assert!(repos[0].get("github").is_none(), "no remote → no github");
9392 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9393 let linked = worktrees
9394 .iter()
9395 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9396 .expect("the linked worktree");
9397 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9399 assert!(linked.get("window_key").is_none());
9400 }
9401
9402 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9408 let main_dir = tempfile::tempdir().unwrap();
9409 let repo = init_repo(main_dir.path());
9410 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9411 repo.set_head("refs/heads/trunk").unwrap();
9412 let wt_parent = tempfile::tempdir().unwrap();
9413 let wt_path = wt_parent.path().join("feature-wt");
9414 add_worktree(&repo, a, &wt_path, "feature");
9415 (main_dir, wt_parent, wt_path)
9416 }
9417
9418 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9422 {
9423 let main_dir = tempfile::tempdir().unwrap();
9424 let repo = init_repo(main_dir.path());
9425 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9426 repo.set_head("refs/heads/trunk").unwrap();
9427 let wt_parent = tempfile::tempdir().unwrap();
9428 let first = wt_parent.path().join("first-wt");
9429 let second = wt_parent.path().join("second-wt");
9430 add_worktree(&repo, a, &first, "first");
9431 add_worktree(&repo, a, &second, "second");
9432 (main_dir, wt_parent, first, second)
9433 }
9434
9435 #[tokio::test]
9436 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9437 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9438 let svc = Arc::new(WorktreesService::new());
9439
9440 let close = |path: PathBuf| {
9450 let svc = svc.clone();
9451 async move {
9452 svc.handle(
9453 "close",
9454 json!({ "path": path, "remove": true, "confirmed": true }),
9455 )
9456 .await
9457 }
9458 };
9459 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9460
9461 assert_eq!(a.unwrap(), json!({ "removed": true }));
9462 assert_eq!(b.unwrap(), json!({ "removed": true }));
9463 assert!(!first.exists());
9464 assert!(!second.exists());
9465 let repo = Repository::open(main_dir.path()).unwrap();
9468 assert!(repo.worktrees().unwrap().is_empty());
9469 }
9470
9471 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9478 let repo = init_repo(dir);
9479 let refname = format!("refs/heads/{branch}");
9480 let head = empty_commit(&repo, Some(&refname), &[], "A");
9481 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9482 .unwrap();
9483 repo.set_head(&refname).unwrap();
9484 let mut cfg = repo.config().unwrap();
9485 cfg.set_str("remote.origin.url", url).unwrap();
9486 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9487 .unwrap();
9488 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9489 .unwrap();
9490 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9491 .unwrap();
9492 repo
9493 }
9494
9495 #[test]
9496 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9497 let dir = tempfile::tempdir().unwrap();
9498 let _repo = pushed_github_repo(
9499 dir.path(),
9500 "https://github.com/rust-works/omni-dev.git",
9501 "feature",
9502 );
9503 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9504 assert_eq!(
9505 ok.target,
9506 PrTarget {
9507 owner: "rust-works".into(),
9508 name: "omni-dev".into(),
9509 branch: "feature".into(),
9510 }
9511 );
9512 assert!(!ok.head_sha.is_empty());
9513 }
9514
9515 #[test]
9516 fn evaluate_local_skips_an_unborn_head() {
9517 let dir = tempfile::tempdir().unwrap();
9518 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9520 }
9521
9522 #[test]
9523 fn evaluate_local_skips_a_branch_with_no_upstream() {
9524 let dir = tempfile::tempdir().unwrap();
9525 let repo = init_repo(dir.path());
9526 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9527 repo.set_head("refs/heads/main").unwrap();
9528 repo.config()
9530 .unwrap()
9531 .set_str("remote.origin.url", "https://github.com/o/r.git")
9532 .unwrap();
9533 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9534 }
9535
9536 #[test]
9537 fn evaluate_local_skips_unpushed_local_commits() {
9538 let dir = tempfile::tempdir().unwrap();
9539 let repo = init_repo(dir.path());
9540 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9541 let a_commit = repo.find_commit(a).unwrap();
9542 repo.reference("refs/remotes/origin/main", a, true, "o")
9544 .unwrap();
9545 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9546 drop(a_commit);
9547 repo.set_head("refs/heads/main").unwrap();
9548 let mut cfg = repo.config().unwrap();
9549 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9550 .unwrap();
9551 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9554 .unwrap();
9555 cfg.set_str("branch.main.remote", "origin").unwrap();
9556 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9557 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9558 }
9559
9560 #[test]
9561 fn evaluate_local_skips_a_detached_head() {
9562 let dir = tempfile::tempdir().unwrap();
9563 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9564 let head = repo.head().unwrap().target().unwrap();
9565 repo.set_head_detached(head).unwrap();
9566 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9567 }
9568
9569 #[test]
9570 fn evaluate_local_skips_a_non_github_remote() {
9571 let dir = tempfile::tempdir().unwrap();
9572 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9573 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9574 }
9575
9576 #[test]
9577 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9578 assert_eq!(
9582 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9583 .unwrap_err()
9584 .kind,
9585 "not-a-repo"
9586 );
9587 }
9588
9589 #[test]
9590 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9591 let req = MergeQueueRequest {
9594 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9595 requester_key: Some("win-9".into()),
9596 check: true,
9597 confirmed: false,
9598 };
9599 let logs = capture_info(|| log_merge_check(&req, 1, 1));
9600 assert!(logs.contains("merge-queue check"), "{logs}");
9601 assert!(logs.contains("win-9"), "{logs}");
9602 assert!(logs.contains("requested=2"), "{logs}");
9603 assert!(logs.contains("eligible=1"), "{logs}");
9604 }
9605
9606 #[test]
9607 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9608 let req = MergeQueueRequest {
9610 paths: vec![PathBuf::from("/a")],
9611 requester_key: None,
9612 check: false,
9613 confirmed: true,
9614 };
9615 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9616 assert!(logs.contains("merge-queue enqueue"), "{logs}");
9617 assert!(logs.contains("queued=2"), "{logs}");
9618 assert!(logs.contains("failed=1"), "{logs}");
9619 }
9620
9621 #[test]
9622 fn evaluate_local_flags_dirty_then_untracked() {
9623 let main_dir = tempfile::tempdir().unwrap();
9625 let repo = init_repo(main_dir.path());
9626 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9627 repo.set_head("refs/heads/main").unwrap();
9628 let wt_parent = tempfile::tempdir().unwrap();
9629 let wt_path = wt_parent.path().join("feature-wt");
9630 add_worktree(&repo, a, &wt_path, "feature");
9631
9632 let clean = evaluate_local(&wt_path).unwrap_err();
9634 assert_ne!(clean.kind, "dirty");
9635 assert_ne!(clean.kind, "untracked");
9636
9637 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9639 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9640
9641 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9643 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9644 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9645 }
9646
9647 #[test]
9648 fn is_conflicting_blocks_only_dirty_and_conflicting() {
9649 assert!(is_conflicting(Some("CONFLICTING")));
9650 assert!(is_conflicting(Some("DIRTY")));
9651 assert!(!is_conflicting(Some("CLEAN")));
9652 assert!(!is_conflicting(Some("BLOCKED")));
9653 assert!(!is_conflicting(Some("UNKNOWN")));
9654 assert!(!is_conflicting(None));
9655 }
9656
9657 #[test]
9658 fn merge_queue_request_parses_batch_and_phase_flags() {
9659 let req: MergeQueueRequest = serde_json::from_value(json!({
9660 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9661 }))
9662 .unwrap();
9663 assert_eq!(req.paths.len(), 2);
9664 assert_eq!(req.requester_key.as_deref(), Some("w1"));
9665 assert!(req.confirmed);
9666 assert!(!req.check);
9667 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9669 assert!(req.paths.is_empty());
9670 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9671 }
9672
9673 #[test]
9674 fn queued_pr_omits_already_queued_when_false() {
9675 let v = serde_json::to_value(QueuedPr {
9676 path: "/a".into(),
9677 number: 5,
9678 already_queued: false,
9679 })
9680 .unwrap();
9681 assert!(v.get("already_queued").is_none(), "{v}");
9682 let v = serde_json::to_value(QueuedPr {
9683 path: "/a".into(),
9684 number: 5,
9685 already_queued: true,
9686 })
9687 .unwrap();
9688 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9689 }
9690
9691 #[tokio::test]
9692 async fn merge_queue_check_on_empty_selection_reports_nothing() {
9693 let svc = WorktreesService::new();
9694 let reply = svc
9695 .handle("merge-queue", json!({ "paths": [], "check": true }))
9696 .await
9697 .unwrap();
9698 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9699 }
9700
9701 #[tokio::test]
9702 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9703 let dir = tempfile::tempdir().unwrap();
9706 let _repo = init_repo(dir.path());
9707 let svc = WorktreesService::new();
9708 let reply = svc
9709 .handle(
9710 "merge-queue",
9711 json!({ "paths": [dir.path()], "check": true }),
9712 )
9713 .await
9714 .unwrap();
9715 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9716 assert_eq!(skipped.len(), 1);
9717 assert_eq!(
9718 skipped[0].get("kind").and_then(Value::as_str),
9719 Some("no-commits")
9720 );
9721 assert!(reply
9722 .get("eligible")
9723 .and_then(Value::as_array)
9724 .unwrap()
9725 .is_empty());
9726 }
9727
9728 fn ready_worktree() -> (tempfile::TempDir, String) {
9732 let dir = tempfile::tempdir().unwrap();
9733 let repo = pushed_github_repo(
9734 dir.path(),
9735 "https://github.com/rust-works/omni-dev.git",
9736 "feature",
9737 );
9738 let head = repo.head().unwrap().target().unwrap().to_string();
9739 (dir, head)
9740 }
9741
9742 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9745 format!(
9746 r#"{{"data":{{"r0":{{"b0":{{
9747 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9748 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9749 ]}}}}}},
9750 "associatedPullRequests":{{"nodes":[{pr}]}}
9751 }}}}}}}}"#
9752 )
9753 }
9754
9755 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9759 let ghdir = tempfile::tempdir().unwrap();
9760 let (bin, _shim) = fake_gh(ghdir.path(), reply);
9761 let paths = vec![head_dir.to_path_buf()];
9762 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9763 if let Some(e) = eligible.first() {
9764 return Ok(e.number);
9765 }
9766 Err(skipped.remove(0).kind)
9767 }
9768
9769 #[test]
9770 fn evaluate_batch_marks_a_ready_pr_eligible() {
9771 let (dir, head) = ready_worktree();
9772 let pr = format!(
9773 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9774 );
9775 assert_eq!(
9776 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9777 Ok(9)
9778 );
9779 }
9780
9781 #[test]
9782 fn evaluate_batch_skips_a_draft_pr() {
9783 let (dir, head) = ready_worktree();
9784 let pr = format!(
9785 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9786 );
9787 assert_eq!(
9788 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9789 Err("draft".to_string())
9790 );
9791 }
9792
9793 #[test]
9794 fn evaluate_batch_skips_a_conflicting_pr() {
9795 let (dir, head) = ready_worktree();
9796 let pr = format!(
9797 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9798 );
9799 assert_eq!(
9800 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9801 Err("conflicting".to_string())
9802 );
9803 }
9804
9805 #[test]
9806 fn evaluate_batch_skips_a_pr_with_failing_checks() {
9807 let (dir, head) = ready_worktree();
9808 let pr = format!(
9809 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9810 );
9811 assert_eq!(
9812 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9813 Err("checks-failing".to_string())
9814 );
9815 }
9816
9817 #[test]
9818 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9819 let (dir, head) = ready_worktree();
9820 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9822 assert_eq!(
9823 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9824 Err("stale".to_string())
9825 );
9826 }
9827
9828 #[test]
9829 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9830 let (dir, head) = ready_worktree();
9831 let reply = format!(
9833 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9834 );
9835 assert_eq!(
9836 network_gate_outcome(dir.path(), &reply),
9837 Err("no-pr".to_string())
9838 );
9839 }
9840
9841 #[test]
9842 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9843 let eligible = vec![
9846 Eligible {
9847 path: PathBuf::from("/wt/a"),
9848 number: 1,
9849 url: "u".into(),
9850 branch: "a".into(),
9851 pr_id: "PR_A".into(),
9852 already_queued: true,
9853 },
9854 Eligible {
9855 path: PathBuf::from("/wt/b"),
9856 number: 2,
9857 url: "u".into(),
9858 branch: "b".into(),
9859 pr_id: "PR_B".into(),
9860 already_queued: false,
9861 },
9862 ];
9863 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9864 assert_eq!(queued.len(), 1);
9865 assert_eq!(queued[0].number, 1);
9866 assert!(queued[0].already_queued);
9867 assert_eq!(failed.len(), 1);
9868 assert_eq!(failed[0].number, 2);
9869 }
9870
9871 #[test]
9872 fn enqueue_eligible_records_a_github_rejection_as_failed() {
9873 let ghdir = tempfile::tempdir().unwrap();
9877 let (bin, _shim) = fake_gh(
9878 ghdir.path(),
9879 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9880 );
9881 let eligible = vec![Eligible {
9882 path: PathBuf::from("/wt/a"),
9883 number: 7,
9884 url: "u".into(),
9885 branch: "a".into(),
9886 pr_id: "PR_A".into(),
9887 already_queued: false,
9888 }];
9889 let (queued, failed) = enqueue_eligible(&bin, eligible);
9890 assert!(queued.is_empty(), "{queued:?}");
9891 assert_eq!(failed.len(), 1);
9892 assert_eq!(failed[0].number, 7);
9893 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9896 }
9897
9898 #[tokio::test]
9899 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9901 let (dir, head) = ready_worktree();
9904 let pr = format!(
9905 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9906 );
9907 let ghdir = tempfile::tempdir().unwrap();
9908 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9909 let svc = WorktreesService::new();
9910 let req = MergeQueueRequest {
9911 paths: vec![dir.path().to_path_buf()],
9912 requester_key: None,
9913 check: true,
9914 confirmed: false,
9915 };
9916 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9920 .await
9921 .unwrap();
9922 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9923 assert_eq!(eligible.len(), 1);
9924 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9925 assert_eq!(
9926 eligible[0].get("branch").and_then(Value::as_str),
9927 Some("feature")
9928 );
9929 }
9930
9931 #[tokio::test]
9932 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9934 let (dir, head) = ready_worktree();
9937 let pr = format!(
9938 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9939 );
9940 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9941 let ghdir = tempfile::tempdir().unwrap();
9942 let guard = shim_lock();
9943 let bin = ghdir.path().join("fake-gh");
9944 write_exec_script(
9945 &bin,
9946 &format!(
9947 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
9948 enqueue =
9949 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9950 ),
9951 );
9952 let svc = WorktreesService::new();
9953 let req = MergeQueueRequest {
9954 paths: vec![dir.path().to_path_buf()],
9955 requester_key: Some("w1".into()),
9956 check: false,
9957 confirmed: true,
9958 };
9959 let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9964 .await
9965 .unwrap();
9966 drop(guard);
9967 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9968 assert_eq!(queued.len(), 1, "{reply}");
9969 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9970 assert!(reply
9971 .get("failed")
9972 .and_then(Value::as_array)
9973 .unwrap()
9974 .is_empty());
9975 }
9976
9977 #[tokio::test]
9978 async fn concurrent_closes_overlap_their_heartbeat_waits() {
9979 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9980 let svc = Arc::new(WorktreesService::new());
9981 for (key, path) in [("w2", &first), ("w3", &second)] {
9983 svc.handle("register", json!({ "key": key, "folders": [path] }))
9984 .await
9985 .unwrap();
9986 }
9987
9988 let spawn_close = |path: PathBuf| {
9989 let svc = svc.clone();
9990 tokio::spawn(async move {
9991 svc.handle(
9992 "close",
9993 json!({
9994 "path": path,
9995 "remove": true,
9996 "confirmed": true,
9997 "requester_key": "w1",
9998 }),
9999 )
10000 .await
10001 })
10002 };
10003 let a = spawn_close(first.clone());
10004 let b = spawn_close(second.clone());
10005
10006 for key in ["w2", "w3"] {
10013 let mut saw_close = false;
10014 for _ in 0..400 {
10015 let hb = svc
10016 .handle("heartbeat", json!({ "key": key }))
10017 .await
10018 .unwrap();
10019 if hb.get("close").and_then(Value::as_bool) == Some(true) {
10020 saw_close = true;
10021 break;
10022 }
10023 tokio::time::sleep(Duration::from_millis(5)).await;
10024 }
10025 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10026 }
10027 assert!(
10028 !a.is_finished() && !b.is_finished(),
10029 "neither close can have finished: both windows are still registered"
10030 );
10031
10032 for key in ["w2", "w3"] {
10034 svc.handle("unregister", json!({ "key": key }))
10035 .await
10036 .unwrap();
10037 }
10038 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10039 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10040 assert!(!first.exists());
10041 assert!(!second.exists());
10042 }
10043
10044 #[tokio::test]
10045 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10046 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10047 let svc = WorktreesService::new();
10048 let report = svc
10051 .handle("close", json!({ "path": wt_path, "remove": true }))
10052 .await
10053 .unwrap();
10054 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10055 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10056 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10057 assert!(report
10058 .get("risks")
10059 .and_then(Value::as_array)
10060 .unwrap()
10061 .is_empty());
10062 assert!(wt_path.exists());
10064 }
10065
10066 #[tokio::test]
10067 async fn close_removes_a_clean_linked_worktree() {
10068 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10069 let svc = WorktreesService::new();
10070 let reply = svc
10071 .handle(
10072 "close",
10073 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10074 )
10075 .await
10076 .unwrap();
10077 assert_eq!(reply, json!({ "removed": true }));
10078 assert!(
10079 !wt_path.exists(),
10080 "the worktree directory should be deleted"
10081 );
10082 }
10083
10084 #[derive(Clone, Default)]
10094 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10095
10096 impl std::io::Write for CaptureWriter {
10097 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10098 self.0.lock().unwrap().extend_from_slice(buf);
10099 Ok(buf.len())
10100 }
10101 fn flush(&mut self) -> std::io::Result<()> {
10102 Ok(())
10103 }
10104 }
10105
10106 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10107 type Writer = Self;
10108 fn make_writer(&'a self) -> Self::Writer {
10109 self.clone()
10110 }
10111 }
10112
10113 fn capture_info(f: impl FnOnce()) -> String {
10116 let writer = CaptureWriter::default();
10117 let subscriber = tracing_subscriber::fmt()
10118 .with_max_level(tracing::Level::INFO)
10119 .with_ansi(false)
10120 .with_writer(writer.clone())
10121 .finish();
10122 tracing::subscriber::with_default(subscriber, f);
10123 let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10124 logs
10125 }
10126
10127 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10131 RebaseRequest {
10132 paths,
10133 requester_key: None,
10134 check: false,
10135 confirmed: false,
10136 keep_conflicts: false,
10137 autostash: false,
10138 onto: None,
10139 }
10140 }
10141
10142 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10150 let main_dir = tempfile::tempdir().unwrap();
10151 let repo = init_repo(main_dir.path());
10152 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10153 repo.set_head("refs/heads/main").unwrap();
10154 let wt_parent = tempfile::tempdir().unwrap();
10155 let wt_path = wt_parent.path().join("feature-wt");
10156 add_worktree(&repo, base, &wt_path, "feature");
10157 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10159 (main_dir, wt_parent, wt_path)
10160 }
10161
10162 #[tokio::test]
10163 async fn rebase_with_refuses_an_empty_selection() {
10164 let svc = WorktreesService::new();
10166 let err = svc
10167 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10168 .await
10169 .unwrap_err()
10170 .to_string();
10171 assert!(err.contains("at least one path"), "{err}");
10172 }
10173
10174 #[tokio::test]
10175 async fn rebase_with_phase_one_reports_without_rebasing() {
10176 let (_main, _parent, wt) = behind_worktree();
10177 let before = Repository::open(&wt).unwrap().head().unwrap().target();
10178
10179 let svc = WorktreesService::new();
10180 let reply = svc
10181 .rebase_with(
10182 RebaseRequest {
10183 check: true,
10184 onto: Some("main".into()),
10185 ..rebase_req(vec![wt.clone()])
10186 },
10187 crate::git::resolve_git_binary(),
10188 )
10189 .await
10190 .unwrap();
10191
10192 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10193 assert_eq!(worktrees.len(), 1, "{reply}");
10194 assert_eq!(
10195 worktrees[0].get("status").and_then(Value::as_str),
10196 Some("would-rebase"),
10197 "{reply}"
10198 );
10199 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10201 assert_eq!(fetches.len(), 1);
10202 assert_eq!(
10203 fetches[0].get("fetched").and_then(Value::as_bool),
10204 Some(false)
10205 );
10206 assert_eq!(
10207 Repository::open(&wt).unwrap().head().unwrap().target(),
10208 before,
10209 "phase 1 must not move the branch"
10210 );
10211 }
10212
10213 #[tokio::test]
10214 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10215 let (_main, _parent, wt) = behind_worktree();
10216 let svc = WorktreesService::new();
10217 let reply = svc
10218 .rebase_with(
10219 RebaseRequest {
10220 confirmed: true,
10221 onto: Some("main".into()),
10222 ..rebase_req(vec![wt.clone()])
10223 },
10224 crate::git::resolve_git_binary(),
10225 )
10226 .await
10227 .unwrap();
10228
10229 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10230 assert_eq!(
10231 worktrees[0].get("status").and_then(Value::as_str),
10232 Some("rebased"),
10233 "{reply}"
10234 );
10235 assert!(
10237 svc.registry.rebasing_paths().is_empty(),
10238 "the rebasing mark must be cleared after the execute"
10239 );
10240 }
10241
10242 #[tokio::test]
10243 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10244 let (_main, _parent, wt) = behind_worktree();
10248 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10249
10250 let svc = WorktreesService::new();
10251 let reply = svc
10252 .rebase_with(
10253 RebaseRequest {
10254 confirmed: true,
10255 onto: Some("main".into()),
10256 ..rebase_req(vec![wt.clone()])
10257 },
10258 crate::git::resolve_git_binary(),
10259 )
10260 .await
10261 .unwrap();
10262
10263 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10264 assert_eq!(
10265 worktrees[0].get("status").and_then(Value::as_str),
10266 Some("skipped"),
10267 "{reply}"
10268 );
10269 assert_eq!(
10270 worktrees[0].get("reason").and_then(Value::as_str),
10271 Some("dirty"),
10272 "{reply}"
10273 );
10274 }
10275
10276 #[tokio::test]
10277 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10278 let (_main, _parent, wt) = behind_worktree();
10284 std::fs::create_dir_all(wt.join(".git")).ok();
10287 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10288 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10289 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10290 assert_ne!(
10291 Repository::open(&wt).unwrap().state(),
10292 RepositoryState::Clean,
10293 "precondition: the worktree looks mid-rebase to git2"
10294 );
10295
10296 let svc = WorktreesService::new();
10297 let reply = svc
10298 .rebase_with(
10299 RebaseRequest {
10300 confirmed: true,
10301 onto: Some("main".into()),
10302 ..rebase_req(vec![wt.clone()])
10303 },
10304 crate::git::resolve_git_binary(),
10305 )
10306 .await
10307 .unwrap();
10308
10309 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10310 assert_eq!(
10311 worktrees[0].get("reason").and_then(Value::as_str),
10312 Some("operation-in-progress"),
10313 "{reply}"
10314 );
10315 assert_ne!(
10317 Repository::open(&wt).unwrap().state(),
10318 RepositoryState::Clean
10319 );
10320 }
10321
10322 #[test]
10323 fn rebase_request_maps_onto_engine_options() {
10324 let req = RebaseRequest {
10325 keep_conflicts: true,
10326 autostash: true,
10327 onto: Some("origin/release".into()),
10328 ..rebase_req(vec![PathBuf::from("/wt")])
10329 };
10330 let opts = req.options(PathBuf::from("/custom/git"));
10331 assert!(opts.keep_conflicts && opts.autostash);
10332 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10333 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10334 assert!(!opts.dry_run);
10338 }
10339
10340 #[test]
10341 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10342 let req = RebaseRequest {
10343 requester_key: Some("win-3".into()),
10344 check: true,
10345 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10346 };
10347 let plan = worktree_rebase::Plan {
10348 fetches: vec![worktree_rebase::FetchOutcome {
10349 repo_root: PathBuf::from("/repo"),
10350 onto: "origin/main".into(),
10351 fetched: true,
10352 ok: false,
10353 detail: Some("host unreachable".into()),
10354 }],
10355 worktrees: vec![
10356 worktree_rebase::WorktreeOutcome {
10357 path: PathBuf::from("/a"),
10358 branch: Some("a".into()),
10359 onto: "origin/main".into(),
10360 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10361 },
10362 worktree_rebase::WorktreeOutcome {
10363 path: PathBuf::from("/b"),
10364 branch: Some("b".into()),
10365 onto: "origin/main".into(),
10366 result: worktree_rebase::RebaseResult::UpToDate,
10367 },
10368 ],
10369 };
10370 let logs = capture_info(|| log_rebase_check(&req, &plan));
10371 assert!(logs.contains("rebase check"), "{logs}");
10372 assert!(logs.contains("win-3"), "{logs}");
10373 assert!(logs.contains("requested=2"), "{logs}");
10374 assert!(logs.contains("pending=1"), "{logs}");
10375 assert!(logs.contains("failed_fetches=1"), "{logs}");
10376 }
10377
10378 #[test]
10379 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10380 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10382 let outcome = |result| worktree_rebase::WorktreeOutcome {
10383 path: PathBuf::from("/x"),
10384 branch: Some("x".into()),
10385 onto: "origin/main".into(),
10386 result,
10387 };
10388 let outcomes = vec![
10389 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10390 outcome(worktree_rebase::RebaseResult::Conflict {
10391 detail: "CONFLICT".into(),
10392 left_in_place: true,
10393 }),
10394 outcome(worktree_rebase::RebaseResult::Skipped {
10395 reason: worktree_rebase::SkipReason::Dirty,
10396 }),
10397 ];
10398 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10399 assert!(logs.contains("rebase execute"), "{logs}");
10400 assert!(logs.contains("rebased=1"), "{logs}");
10401 assert!(logs.contains("conflicts=1"), "{logs}");
10402 assert!(logs.contains("left_in_place=1"), "{logs}");
10403 assert!(logs.contains("skipped=1"), "{logs}");
10404 assert!(logs.contains(r#"requester="-""#), "{logs}");
10405 }
10406
10407 fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10411 PushRequest {
10412 paths,
10413 requester_key: None,
10414 check: false,
10415 confirmed: false,
10416 }
10417 }
10418
10419 fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10426 let _guard = crate::git::worktree_batch::test_serial_lock();
10429 let root = tempfile::tempdir().unwrap();
10430 let origin = root.path().join("origin.git");
10431 let local = root.path().join("local");
10432 let wt = root.path().join("feature-wt");
10433 std::fs::create_dir_all(&origin).unwrap();
10434 std::fs::create_dir_all(&local).unwrap();
10435
10436 let git = |dir: &Path, args: &[&str]| {
10437 let out = crate::git::worktree_batch::run_git_in(
10438 &crate::git::resolve_git_binary(),
10439 dir,
10440 args,
10441 )
10442 .unwrap();
10443 assert!(
10444 out.status.success(),
10445 "git {args:?} failed: {}",
10446 String::from_utf8_lossy(&out.stderr)
10447 );
10448 };
10449
10450 git(&origin, &["init", "--bare", "-b", "main"]);
10451 git(&local, &["init", "-b", "main"]);
10452 git(&local, &["config", "user.name", "Test"]);
10453 git(&local, &["config", "user.email", "test@example.com"]);
10454 git(&local, &["config", "commit.gpgsign", "false"]);
10455 std::fs::write(local.join("f.txt"), "base\n").unwrap();
10456 git(&local, &["add", "f.txt"]);
10457 git(&local, &["commit", "-m", "base"]);
10458 git(
10459 &local,
10460 &["remote", "add", "origin", origin.to_str().unwrap()],
10461 );
10462 git(&local, &["push", "-u", "origin", "main"]);
10463 git(
10464 &local,
10465 &[
10466 "worktree",
10467 "add",
10468 "-b",
10469 "feature",
10470 wt.to_str().unwrap(),
10471 "main",
10472 ],
10473 );
10474 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10475 git(&wt, &["add", "g.txt"]);
10476 git(&wt, &["commit", "-m", "work"]);
10477 git(&wt, &["push", "-u", "origin", "feature"]);
10478 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10480
10481 (root, origin, std::fs::canonicalize(&wt).unwrap())
10482 }
10483
10484 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10486 Repository::open_bare(origin)
10487 .unwrap()
10488 .refname_to_id(refname)
10489 .ok()
10490 }
10491
10492 #[tokio::test]
10493 async fn push_with_refuses_an_empty_selection() {
10494 let svc = WorktreesService::new();
10496 let err = svc
10497 .push_with(push_req(Vec::new()), PathBuf::from("git"))
10498 .await
10499 .unwrap_err()
10500 .to_string();
10501 assert!(err.contains("at least one path"), "{err}");
10502 }
10503
10504 #[tokio::test]
10505 async fn push_with_phase_one_reports_without_publishing() {
10506 let (_root, origin, wt) = rewritten_worktree();
10507 let before = origin_tip(&origin, "refs/heads/feature");
10508
10509 let svc = WorktreesService::new();
10510 let reply = svc
10511 .push_with(
10512 PushRequest {
10513 check: true,
10514 ..push_req(vec![wt.clone()])
10515 },
10516 crate::git::resolve_git_binary(),
10517 )
10518 .await
10519 .unwrap();
10520
10521 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10522 assert_eq!(worktrees.len(), 1, "{reply}");
10523 assert_eq!(
10524 worktrees[0].get("status").and_then(Value::as_str),
10525 Some("would-force"),
10526 "{reply}"
10527 );
10528 assert!(
10529 reply.get("fetches").is_none(),
10530 "a push plan contacts no remote, so it reports no fetches: {reply}"
10531 );
10532 assert_eq!(
10533 origin_tip(&origin, "refs/heads/feature"),
10534 before,
10535 "phase 1 must publish nothing"
10536 );
10537 assert!(
10538 svc.registry.pushing_paths().is_empty(),
10539 "phase 1 must not mark a row as in flight"
10540 );
10541 }
10542
10543 #[tokio::test]
10544 async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10545 let (_root, origin, wt) = rewritten_worktree();
10546 let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10547
10548 let svc = WorktreesService::new();
10549 let reply = svc
10550 .push_with(
10551 PushRequest {
10552 confirmed: true,
10553 ..push_req(vec![wt.clone()])
10554 },
10555 crate::git::resolve_git_binary(),
10556 )
10557 .await
10558 .unwrap();
10559
10560 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10561 assert_eq!(
10562 worktrees[0].get("status").and_then(Value::as_str),
10563 Some("pushed"),
10564 "{reply}"
10565 );
10566 assert_eq!(
10567 worktrees[0].get("forced").and_then(Value::as_bool),
10568 Some(true),
10569 "a rewritten branch is published under the lease: {reply}"
10570 );
10571 assert_eq!(
10572 origin_tip(&origin, "refs/heads/feature"),
10573 rewritten,
10574 "the remote must carry the rewritten tip"
10575 );
10576 assert!(
10577 svc.registry.pushing_paths().is_empty(),
10578 "the pushing mark must be cleared after the execute — a push writes no \
10579 on-disk state, so nothing else could ever correct a leftover"
10580 );
10581 }
10582
10583 #[tokio::test]
10584 async fn push_resolves_the_git_binary_for_itself() {
10585 let (_root, origin, wt) = rewritten_worktree();
10589 let before = origin_tip(&origin, "refs/heads/feature");
10590
10591 let svc = WorktreesService::new();
10592 let reply = svc
10593 .push(PushRequest {
10594 check: true,
10595 ..push_req(vec![wt])
10596 })
10597 .await
10598 .unwrap();
10599
10600 assert_eq!(
10601 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10602 .get("status")
10603 .and_then(Value::as_str),
10604 Some("would-force"),
10605 "{reply}"
10606 );
10607 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10608 }
10609
10610 #[tokio::test]
10611 async fn push_with_defaults_to_report_only_without_confirmation() {
10612 let (_root, origin, wt) = rewritten_worktree();
10615 let before = origin_tip(&origin, "refs/heads/feature");
10616
10617 let svc = WorktreesService::new();
10618 let reply = svc
10619 .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10620 .await
10621 .unwrap();
10622
10623 assert_eq!(
10624 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10625 .get("status")
10626 .and_then(Value::as_str),
10627 Some("would-force"),
10628 "{reply}"
10629 );
10630 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10631 }
10632
10633 #[tokio::test]
10634 async fn push_with_refuses_to_force_the_remote_default_branch() {
10635 let (root, origin, _wt) = rewritten_worktree();
10638 let local = root.path().join("local");
10639 let before = origin_tip(&origin, "refs/heads/main");
10640 crate::git::worktree_batch::run_git_in(
10641 &crate::git::resolve_git_binary(),
10642 &local,
10643 &["commit", "--amend", "-m", "rewritten main"],
10644 )
10645 .unwrap();
10646
10647 let svc = WorktreesService::new();
10648 let reply = svc
10649 .push_with(
10650 PushRequest {
10651 confirmed: true,
10652 ..push_req(vec![local.clone()])
10653 },
10654 crate::git::resolve_git_binary(),
10655 )
10656 .await
10657 .unwrap();
10658
10659 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10660 assert_eq!(
10661 worktrees[0].get("reason").and_then(Value::as_str),
10662 Some("default-branch-force-push"),
10663 "{reply}"
10664 );
10665 assert_eq!(
10666 origin_tip(&origin, "refs/heads/main"),
10667 before,
10668 "the default branch's published history must be untouched"
10669 );
10670 }
10671
10672 #[test]
10673 fn log_push_check_separates_the_force_count_from_the_pending_count() {
10674 let req = PushRequest {
10675 requester_key: Some("win-7".into()),
10676 check: true,
10677 ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10678 };
10679 let outcome = |result| worktree_push::WorktreeOutcome {
10680 path: PathBuf::from("/x"),
10681 branch: Some("x".into()),
10682 remote: "origin".into(),
10683 remote_branch: "x".into(),
10684 result,
10685 };
10686 let plan = worktree_push::Plan {
10687 worktrees: vec![
10688 outcome(worktree_push::PushResult::WouldForce {
10689 ahead: 1,
10690 behind: 1,
10691 }),
10692 outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10693 outcome(worktree_push::PushResult::Skipped {
10694 reason: worktree_push::SkipReason::DefaultBranchForcePush,
10695 }),
10696 ],
10697 };
10698 let logs = capture_info(|| log_push_check(&req, &plan));
10699 assert!(logs.contains("push check"), "{logs}");
10700 assert!(logs.contains("pending=2"), "{logs}");
10701 assert!(logs.contains("forced=1"), "{logs}");
10702 assert!(logs.contains("skipped=1"), "{logs}");
10703 assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10704 }
10705
10706 #[test]
10707 fn log_push_execute_counts_lease_refusals_separately() {
10708 let req = push_req(vec![PathBuf::from("/a")]);
10709 let outcome = |result| worktree_push::WorktreeOutcome {
10710 path: PathBuf::from("/x"),
10711 branch: Some("x".into()),
10712 remote: "origin".into(),
10713 remote_branch: "x".into(),
10714 result,
10715 };
10716 let outcomes = vec![
10717 outcome(worktree_push::PushResult::Pushed { forced: true }),
10718 outcome(worktree_push::PushResult::Pushed { forced: false }),
10719 outcome(worktree_push::PushResult::Created),
10720 outcome(worktree_push::PushResult::Rejected {
10721 detail: "stale info".into(),
10722 stale: true,
10723 }),
10724 outcome(worktree_push::PushResult::Rejected {
10725 detail: "pre-receive hook declined".into(),
10726 stale: false,
10727 }),
10728 ];
10729 let logs = capture_info(|| log_push_execute(&req, &outcomes));
10730 assert!(logs.contains("push execute"), "{logs}");
10731 assert!(logs.contains("pushed=2"), "{logs}");
10732 assert!(logs.contains("forced=1"), "{logs}");
10733 assert!(logs.contains("created=1"), "{logs}");
10734 assert!(logs.contains("rejected=2"), "{logs}");
10735 assert!(
10736 logs.contains("stale_rejected=1"),
10737 "a lease refusal is the interesting half of a rejection: {logs}"
10738 );
10739 }
10740
10741 #[test]
10742 fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10743 let dir = tempfile::tempdir().unwrap();
10744 let path = canonical(dir.path());
10745
10746 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10747 assert!(!quiet.pushing);
10748 let json = serde_json::to_value(&quiet).unwrap();
10749 assert!(
10750 json.get("pushing").is_none(),
10751 "an idle row stays byte-identical for an older client: {json}"
10752 );
10753
10754 let busy = worktree_entry(
10755 &path,
10756 true,
10757 &HashMap::new(),
10758 &InFlight {
10759 pushing: [path.clone()].into(),
10760 rebasing: HashSet::new(),
10761 },
10762 );
10763 assert!(busy.pushing, "the registry's transient mark rides through");
10764 assert!(
10765 !busy.rebasing,
10766 "the two cues are independent — a push must not read as a rebase"
10767 );
10768 assert_eq!(
10769 serde_json::to_value(&busy).unwrap()["pushing"],
10770 serde_json::json!(true)
10771 );
10772 }
10773
10774 #[test]
10775 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10776 assert_eq!(operation_slug(RepositoryState::Clean), None);
10777 assert_eq!(
10778 operation_slug(RepositoryState::Rebase).as_deref(),
10779 Some("rebase")
10780 );
10781 assert_eq!(
10782 operation_slug(RepositoryState::RebaseMerge).as_deref(),
10783 Some("rebase"),
10784 "the merge-backend rebase is still just a rebase to the user"
10785 );
10786 assert_eq!(
10787 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10788 Some("rebase-interactive")
10789 );
10790 assert_eq!(
10791 operation_slug(RepositoryState::Merge).as_deref(),
10792 Some("merge")
10793 );
10794 assert_eq!(
10795 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10796 Some("cherry-pick")
10797 );
10798 assert_eq!(
10799 operation_slug(RepositoryState::RevertSequence).as_deref(),
10800 Some("revert")
10801 );
10802 assert_eq!(
10803 operation_slug(RepositoryState::Bisect).as_deref(),
10804 Some("bisect")
10805 );
10806 assert_eq!(
10807 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10808 Some("apply-mailbox")
10809 );
10810 }
10811
10812 #[test]
10813 fn git_status_omits_operation_for_a_clean_worktree() {
10814 let dir = tempfile::tempdir().unwrap();
10815 let _repo = diverging_repo(dir.path());
10816 assert_eq!(
10817 git_status(dir.path()).operation,
10818 None,
10819 "a clean worktree carries no operation, so the field stays off the wire"
10820 );
10821 }
10822
10823 #[test]
10824 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10825 let main_dir = tempfile::tempdir().unwrap();
10826 let repo = init_repo(main_dir.path());
10827 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10828 repo.set_head("refs/heads/main").unwrap();
10829 let path = canonical(main_dir.path());
10830
10831 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10832 assert!(!quiet.rebasing);
10833 let json = serde_json::to_value(&quiet).unwrap();
10835 assert!(json.get("rebasing").is_none(), "{json}");
10836 assert!(json.get("operation").is_none(), "{json}");
10837
10838 let busy = worktree_entry(
10839 &path,
10840 true,
10841 &HashMap::new(),
10842 &InFlight {
10843 rebasing: std::iter::once(path.clone()).collect(),
10844 pushing: HashSet::new(),
10845 },
10846 );
10847 assert!(busy.rebasing, "the registry's transient mark rides through");
10848 assert_eq!(
10849 serde_json::to_value(&busy).unwrap()["rebasing"],
10850 serde_json::Value::Bool(true)
10851 );
10852 }
10853
10854 #[test]
10855 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10856 assert_eq!(note_kinds(&[]), "-");
10857 assert_eq!(
10858 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10859 "dirty,untracked"
10860 );
10861 }
10862
10863 #[test]
10864 fn is_self_close_true_only_when_requester_owns_an_open_window() {
10865 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10866 assert!(is_self_close(Some("w1"), &windows));
10867 assert!(
10868 !is_self_close(Some("w3"), &windows),
10869 "requester owns no window"
10870 );
10871 assert!(!is_self_close(None, &windows), "no requester");
10872 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10873 }
10874
10875 #[test]
10876 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10877 let logs = capture_info(|| {
10878 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10879 assert_eq!(reply, json!({ "removed": true }));
10880 });
10881 assert!(
10882 logs.contains("worktrees close: linked worktree pruned"),
10883 "a successful prune must log an INFO audit line, got: {logs}"
10884 );
10885 assert!(
10886 logs.contains("/wt/feature"),
10887 "the target path must ride the line, got: {logs}"
10888 );
10889 }
10890
10891 #[test]
10892 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10893 let logs = capture_info(|| {
10898 let reply =
10899 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10900 assert_eq!(reply, json!({ "removed": true }));
10901 });
10902 assert!(
10903 logs.contains("worktrees close: nothing to prune, worktree already removed"),
10904 "an already-gone close must log its own outcome, got: {logs}"
10905 );
10906 assert!(
10907 !logs.contains("linked worktree pruned"),
10908 "an already-gone close must not claim it pruned, got: {logs}"
10909 );
10910 }
10911
10912 #[test]
10913 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10914 let logs = capture_info(|| {
10916 let err = log_close_error(
10917 Path::new("/wt/feature"),
10918 "safety check",
10919 anyhow!("not a git worktree"),
10920 );
10921 assert_eq!(
10922 err.to_string(),
10923 "not a git worktree",
10924 "err propagates unchanged"
10925 );
10926 });
10927 assert!(
10928 logs.contains("worktrees close: safety check failed"),
10929 "a failed phase must log an ERROR audit line, got: {logs}"
10930 );
10931 assert!(
10932 logs.contains("not a git worktree"),
10933 "the cause must ride the line, got: {logs}"
10934 );
10935 assert!(
10936 logs.contains("/wt/feature"),
10937 "the target path must ride the line, got: {logs}"
10938 );
10939 }
10940
10941 #[test]
10942 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10943 let logs = capture_info(|| {
10944 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10945 assert!(err.is_err(), "a prune failure must propagate");
10946 });
10947 assert!(
10948 logs.contains("worktrees close: worktree prune failed"),
10949 "a prune failure must log a WARN audit line, got: {logs}"
10950 );
10951 assert!(
10952 logs.contains("locked"),
10953 "the failure cause must ride the line, got: {logs}"
10954 );
10955 }
10956
10957 #[test]
10958 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10959 let git = GitSafety {
10960 is_main: false,
10961 removable: true,
10962 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10963 info: vec![],
10964 };
10965 let logs = capture_info(|| {
10966 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10967 });
10968 assert!(
10969 logs.contains("worktrees close: safety check"),
10970 "phase-1 must log a safety-check line, got: {logs}"
10971 );
10972 assert!(
10973 logs.contains("/wt/feature"),
10974 "the path must ride the line, got: {logs}"
10975 );
10976 assert!(
10977 logs.contains("window_key=\"win-42\""),
10978 "the owning window key must ride the line, got: {logs}"
10979 );
10980 assert!(logs.contains("removable=true"), "got: {logs}");
10981 assert!(logs.contains("is_main=false"), "got: {logs}");
10982 assert!(logs.contains("open=true"), "got: {logs}");
10983 assert!(
10984 logs.contains("risks=dirty,untracked"),
10985 "the blocking risk kinds must ride the line, got: {logs}"
10986 );
10987 }
10988
10989 #[test]
10990 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10991 let git = GitSafety {
10992 is_main: false,
10993 removable: true,
10994 risks: vec![],
10995 info: vec![],
10996 };
10997 let logs = capture_info(|| {
10998 log_safety_check(Path::new("/wt/feature"), None, &git, false);
10999 });
11000 assert!(
11001 logs.contains("window_key=\"-\""),
11002 "no owning window → dash, got: {logs}"
11003 );
11004 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11005 }
11006
11007 #[test]
11008 fn log_executing_logs_the_routing_decision() {
11009 let logs = capture_info(|| {
11010 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11011 });
11012 assert!(
11013 logs.contains("worktrees close: executing"),
11014 "phase-2 must log the execute routing, got: {logs}"
11015 );
11016 assert!(
11017 logs.contains("requester=\"win-7\""),
11018 "the requester key must ride the line, got: {logs}"
11019 );
11020 assert!(logs.contains("remove=true"), "got: {logs}");
11021 assert!(logs.contains("self_close=false"), "got: {logs}");
11022 assert!(logs.contains("cross_window=3"), "got: {logs}");
11023 }
11024
11025 #[test]
11026 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11027 let logs = capture_info(|| {
11028 log_close_abort(
11029 Path::new("/wt/feature"),
11030 &anyhow!("window(s) did not close in time: win-9"),
11031 );
11032 });
11033 assert!(
11034 logs.contains("worktrees close: aborted"),
11035 "an abort must log a WARN audit line, got: {logs}"
11036 );
11037 assert!(
11038 logs.contains("/wt/feature"),
11039 "the path must ride the line, got: {logs}"
11040 );
11041 assert!(
11042 logs.contains("win-9"),
11043 "the still-open window must ride the line, got: {logs}"
11044 );
11045 }
11046
11047 #[test]
11048 fn log_window_closed_logs_the_no_removal_outcome() {
11049 let logs = capture_info(|| {
11050 log_window_closed(Path::new("/wt/feature"));
11051 });
11052 assert!(
11053 logs.contains("worktrees close: window closed, no removal"),
11054 "a remove:false close must log the no-removal outcome, got: {logs}"
11055 );
11056 assert!(
11057 logs.contains("/wt/feature"),
11058 "the path must ride the line, got: {logs}"
11059 );
11060 }
11061
11062 #[test]
11063 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11064 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11068 let admin = main.path().join(".git").join("worktrees").join("feature");
11069 assert!(admin.exists(), "admin metadata should exist before removal");
11070
11071 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11072
11073 assert!(!wt_path.exists(), "the working directory should be gone");
11074 assert!(!admin.exists(), "the admin metadata should be pruned");
11075 let main_repo = Repository::open(main.path()).unwrap();
11076 assert_eq!(
11077 main_repo.worktrees().unwrap().len(),
11078 0,
11079 "git should no longer track the worktree"
11080 );
11081 }
11082
11083 #[test]
11084 fn remove_worktree_recovers_a_half_removed_orphan() {
11085 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11090 let admin = main.path().join(".git").join("worktrees").join("feature");
11091 std::fs::remove_dir_all(&admin).unwrap();
11093 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11094 assert!(
11095 Repository::open(&wt_path).is_err(),
11096 "the orphan should not open as a repo"
11097 );
11098
11099 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11100 assert!(
11101 !wt_path.exists(),
11102 "the leftover directory should be removed"
11103 );
11104 }
11105
11106 fn orphaned_admin_worktree() -> (
11114 tempfile::TempDir,
11115 PathBuf,
11116 tempfile::TempDir,
11117 PathBuf,
11118 PathBuf,
11119 ) {
11120 let main_dir = tempfile::tempdir().unwrap();
11121 let main_root = main_dir.path().canonicalize().unwrap();
11122 let repo = init_repo(&main_root);
11123 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11124 repo.set_head("refs/heads/trunk").unwrap();
11125 let wt_parent = tempfile::tempdir().unwrap();
11126 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11127 add_worktree(&repo, a, &wt_path, "feature");
11128 let admin = main_root.join(".git").join("worktrees").join("feature");
11129 assert!(admin.exists(), "admin metadata exists before the orphaning");
11130 std::fs::remove_dir_all(&wt_path).unwrap();
11132 (main_dir, main_root, wt_parent, wt_path, admin)
11133 }
11134
11135 fn window_on(folder: &Path) -> WindowEntry {
11136 WindowEntry {
11137 key: "w".to_string(),
11138 folders: vec![folder.to_path_buf()],
11139 repo: None,
11140 title: None,
11141 pid: None,
11142 last_seen: Utc::now(),
11143 }
11144 }
11145
11146 #[test]
11147 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11148 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11152
11153 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11154
11155 assert_eq!(
11156 removed,
11157 Removal::Pruned,
11158 "the orphaned admin must be pruned"
11159 );
11160 assert!(!admin.exists(), "the admin metadata should be gone");
11161 let main_repo = Repository::open(&main_root).unwrap();
11162 assert!(
11163 main_repo.worktrees().unwrap().is_empty(),
11164 "git should no longer track the orphaned worktree"
11165 );
11166 }
11167
11168 #[test]
11169 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11170 let main_dir = tempfile::tempdir().unwrap();
11174 let main_root = main_dir.path().canonicalize().unwrap();
11175 let repo = init_repo(&main_root);
11176 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11177 repo.set_head("refs/heads/trunk").unwrap();
11178 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11180 let wt_path = main_root.join(".nested").join("feature-wt");
11181 add_worktree(&repo, a, &wt_path, "feature");
11182 let admin = main_root.join(".git").join("worktrees").join("feature");
11183 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11184
11185 let removed = remove_worktree(&wt_path, &[]).unwrap();
11186
11187 assert_eq!(removed, Removal::Pruned);
11188 assert!(!admin.exists(), "the admin metadata should be gone");
11189 }
11190
11191 #[test]
11192 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11193 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11197 std::fs::remove_dir_all(&admin).unwrap();
11199
11200 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11201
11202 assert_eq!(removed, Removal::AlreadyGone);
11203 }
11204
11205 #[test]
11206 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11207 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11208 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11211 assert!(
11212 roots.contains(&main_root),
11213 "the owning main repo must be a candidate, got: {roots:?}"
11214 );
11215 }
11216
11217 #[test]
11218 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11219 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11223 let junk = tempfile::tempdir().unwrap();
11224
11225 let removed =
11226 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11227
11228 assert_eq!(removed, Removal::Pruned);
11229 assert!(
11230 !admin.exists(),
11231 "the real owner must still prune the orphan"
11232 );
11233 }
11234
11235 #[test]
11236 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11237 let main_dir = tempfile::tempdir().unwrap();
11241 let main_root = main_dir.path().canonicalize().unwrap();
11242 let repo = init_repo(&main_root);
11243 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11244 repo.set_head("refs/heads/trunk").unwrap();
11245 let wt_parent = tempfile::tempdir().unwrap();
11246 let wt_root = wt_parent.path().canonicalize().unwrap();
11247 let orphan = wt_root.join("orphan-wt");
11248 let sibling = wt_root.join("sibling-wt");
11249 add_worktree(&repo, a, &orphan, "orphan");
11250 add_worktree(&repo, a, &sibling, "sibling");
11251 let admin = main_root.join(".git").join("worktrees").join("orphan");
11252 std::fs::remove_dir_all(&orphan).unwrap();
11253
11254 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11257
11258 assert_eq!(removed, Removal::Pruned);
11259 assert!(
11260 !admin.exists(),
11261 "the orphan's admin metadata must be pruned"
11262 );
11263 }
11264
11265 #[test]
11266 fn prune_orphaned_admin_refuses_a_locked_orphan() {
11267 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11271 let main_repo = Repository::open(&main_root).unwrap();
11272 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11273 main_repo
11274 .find_worktree(&name)
11275 .unwrap()
11276 .lock(Some("in use"))
11277 .unwrap();
11278
11279 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11280
11281 assert!(
11282 err.to_string().contains("locked"),
11283 "a locked orphan must be refused, got: {err:#}"
11284 );
11285 assert!(admin.exists(), "a refused prune must leave the admin entry");
11286 }
11287
11288 #[test]
11289 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11290 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11291 assert!(!is_orphaned_worktree(&wt_path));
11293 assert!(!is_orphaned_worktree(main.path()));
11295 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11297 .unwrap();
11298 assert!(is_orphaned_worktree(&wt_path));
11299 }
11300
11301 #[test]
11302 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11303 let tmp = tempfile::tempdir().unwrap();
11304 let missing = tmp.path().join("gone");
11305 assert!(remove_dir_all_retrying(&missing).is_ok());
11306 }
11307
11308 #[test]
11309 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11310 use std::io::Error;
11311 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11312 assert!(
11313 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11314 "errno {errno} is the concurrent-writer race and must be retried"
11315 );
11316 }
11317 for errno in [
11320 nix::libc::EACCES,
11321 nix::libc::EPERM,
11322 nix::libc::EROFS,
11323 nix::libc::ENOTDIR,
11324 ] {
11325 assert!(
11326 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11327 "errno {errno} is permanent and must not be retried"
11328 );
11329 }
11330 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11332 }
11333
11334 #[test]
11335 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11336 let tmp = tempfile::tempdir().unwrap();
11340 let file = tmp.path().join("not-a-directory");
11341 std::fs::write(&file, b"x").unwrap();
11342
11343 let mut attempts = 0;
11344 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11345 attempts += 1;
11346 std::fs::remove_dir_all(&file)
11347 })
11348 .unwrap_err();
11349
11350 assert_eq!(attempts, 1, "a permanent error must not be retried");
11351 assert!(
11352 err.to_string()
11353 .contains("failed to remove worktree directory"),
11354 "unexpected error: {err:#}"
11355 );
11356 assert!(err.source().is_some(), "the io::Error cause is preserved");
11357 assert!(file.exists());
11358 }
11359
11360 #[test]
11361 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11362 let tmp = tempfile::tempdir().unwrap();
11366 let mut attempts = 0;
11367 let backoff = [Duration::ZERO, Duration::ZERO];
11368 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11369 attempts += 1;
11370 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11371 })
11372 .unwrap_err();
11373
11374 assert_eq!(attempts, backoff.len() + 1);
11376 assert!(
11377 err.to_string()
11378 .contains("failed to remove worktree directory"),
11379 "unexpected error: {err:#}"
11380 );
11381 }
11382
11383 #[test]
11384 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11385 let tmp = tempfile::tempdir().unwrap();
11388 let mut attempts = 0;
11389 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11390 attempts += 1;
11391 if attempts < 3 {
11392 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11393 } else {
11394 Ok(())
11395 }
11396 });
11397 assert!(result.is_ok(), "{result:?}");
11398 assert_eq!(attempts, 3);
11399 }
11400
11401 #[test]
11402 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11403 let tmp = tempfile::tempdir().unwrap();
11406 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11407 assert!(!is_orphaned_worktree(tmp.path()));
11408 }
11409
11410 #[test]
11411 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11412 let tmp = tempfile::tempdir().unwrap();
11415 let plain = tmp.path().join("plain");
11416 std::fs::create_dir(&plain).unwrap();
11417
11418 let err = remove_worktree(&plain, &[]).unwrap_err();
11419
11420 assert!(
11421 err.to_string().contains("not a git worktree"),
11422 "unexpected error: {err:#}"
11423 );
11424 assert!(plain.exists(), "a non-worktree path must be left alone");
11425 }
11426
11427 #[test]
11428 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11429 use std::sync::atomic::{AtomicBool, Ordering};
11435 use std::sync::Arc;
11436
11437 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11438 let nested = wt_path.join("target").join("nested");
11444 std::fs::create_dir_all(&nested).unwrap();
11445
11446 let stop = Arc::new(AtomicBool::new(false));
11447 let writer_stop = Arc::clone(&stop);
11448 let writer_dir = nested;
11449 let writer = std::thread::spawn(move || {
11450 let mut n = 0u64;
11451 let deadline = std::time::Instant::now() + Duration::from_millis(400);
11454 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11455 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11460 n += 1;
11461 }
11462 });
11463
11464 let result = remove_worktree(&wt_path, &[]);
11465 stop.store(true, Ordering::Relaxed);
11466 writer.join().unwrap();
11467
11468 assert!(
11469 result.is_ok(),
11470 "removal should retry past the writer: {result:?}"
11471 );
11472 assert!(!wt_path.exists(), "the worktree directory should be gone");
11473 }
11474
11475 #[tokio::test]
11476 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11477 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11478 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11480
11481 let svc = WorktreesService::new();
11482 let report = svc
11483 .handle("close", json!({ "path": wt_path, "remove": true }))
11484 .await
11485 .unwrap();
11486 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11487 assert!(
11488 risks
11489 .iter()
11490 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11491 "expected an untracked risk: {report}"
11492 );
11493 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11495 assert!(wt_path.exists());
11497 }
11498
11499 #[tokio::test]
11500 async fn close_confirmed_removes_a_dirty_worktree() {
11501 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11502 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11503 let svc = WorktreesService::new();
11504 let reply = svc
11506 .handle(
11507 "close",
11508 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11509 )
11510 .await
11511 .unwrap();
11512 assert_eq!(reply, json!({ "removed": true }));
11513 assert!(!wt_path.exists());
11514 }
11515
11516 #[tokio::test]
11517 async fn close_refuses_to_remove_the_main_working_tree() {
11518 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11519 let svc = WorktreesService::new();
11520 let report = svc
11522 .handle("close", json!({ "path": main.path(), "remove": true }))
11523 .await
11524 .unwrap();
11525 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11526 assert_eq!(
11527 report.get("removable").and_then(Value::as_bool),
11528 Some(false)
11529 );
11530 assert!(svc
11533 .handle(
11534 "close",
11535 json!({ "path": main.path(), "remove": true, "confirmed": true }),
11536 )
11537 .await
11538 .is_err());
11539 assert!(main.path().exists());
11540 }
11541
11542 #[tokio::test]
11543 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11544 let main_dir = tempfile::tempdir().unwrap();
11548 let repo = init_repo(main_dir.path());
11549 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11550 repo.set_head("refs/heads/trunk").unwrap();
11551 let wt_parent = tempfile::tempdir().unwrap();
11552 let wt_path = wt_parent.path().join("main-wt");
11553 add_worktree(&repo, a, &wt_path, "main");
11554
11555 let svc = WorktreesService::new();
11556 let reply = svc
11557 .handle(
11558 "close",
11559 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11560 )
11561 .await
11562 .unwrap();
11563 assert_eq!(reply, json!({ "removed": true }));
11564 assert!(!wt_path.exists());
11565 assert!(
11567 repo.find_branch("main", git2::BranchType::Local).is_ok(),
11568 "the default branch must survive worktree removal"
11569 );
11570 }
11571
11572 #[tokio::test]
11573 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11574 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11575 let svc = WorktreesService::new();
11576 svc.handle(
11578 "close",
11579 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11580 )
11581 .await
11582 .unwrap();
11583 let reply = svc
11586 .handle(
11587 "close",
11588 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11589 )
11590 .await
11591 .unwrap();
11592 assert_eq!(reply, json!({ "removed": true }));
11593 }
11594
11595 #[tokio::test]
11596 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11597 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11602 let svc = WorktreesService::new();
11603 svc.handle(
11606 "register",
11607 register_payload("main-w", None, &main_root.display().to_string()),
11608 )
11609 .await
11610 .unwrap();
11611
11612 let before = svc.handle("tree", Value::Null).await.unwrap();
11614 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11615 assert_eq!(
11616 worktrees_before, 2,
11617 "the orphaned row is present before close"
11618 );
11619
11620 let reply = svc
11621 .handle(
11622 "close",
11623 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11624 )
11625 .await
11626 .unwrap();
11627 assert_eq!(reply, json!({ "removed": true }));
11628
11629 assert!(!admin.exists(), "the admin metadata must be pruned");
11631 let after = svc.handle("tree", Value::Null).await.unwrap();
11632 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11633 assert_eq!(worktrees_after, 1, "only the main working tree remains");
11634 }
11635
11636 #[tokio::test]
11637 async fn close_safety_check_detects_detached_head_unreachable_commits() {
11638 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11639 let wt_repo = Repository::open(&wt_path).unwrap();
11642 let parent_oid = wt_repo.head().unwrap().target().unwrap();
11643 let parent = wt_repo.find_commit(parent_oid).unwrap();
11644 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11645 wt_repo.set_head_detached(orphan).unwrap();
11646
11647 let svc = WorktreesService::new();
11648 let report = svc
11649 .handle("close", json!({ "path": wt_path, "remove": true }))
11650 .await
11651 .unwrap();
11652 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11653 assert!(
11654 risks
11655 .iter()
11656 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11657 "expected an unreachable-commits risk: {report}"
11658 );
11659 }
11660
11661 #[tokio::test]
11662 async fn close_self_close_removes_when_the_requester_owns_the_target() {
11663 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11664 let svc = WorktreesService::new();
11665 svc.handle(
11669 "register",
11670 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11671 )
11672 .await
11673 .unwrap();
11674 let reply = svc
11675 .handle(
11676 "close",
11677 json!({
11678 "path": wt_path,
11679 "remove": true,
11680 "confirmed": true,
11681 "requester_key": "w1",
11682 }),
11683 )
11684 .await
11685 .unwrap();
11686 assert_eq!(reply, json!({ "removed": true }));
11687 assert!(!wt_path.exists());
11688 }
11689
11690 #[tokio::test]
11691 async fn close_safety_check_surfaces_the_owning_window() {
11692 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11693 let svc = WorktreesService::new();
11694 svc.handle(
11697 "register",
11698 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11699 )
11700 .await
11701 .unwrap();
11702 let report = svc
11703 .handle("close", json!({ "path": wt_path, "remove": true }))
11704 .await
11705 .unwrap();
11706 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11707 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11708 assert_eq!(
11709 report.get("window_folder_count").and_then(Value::as_u64),
11710 Some(2)
11711 );
11712 }
11713
11714 #[tokio::test]
11715 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11716 let svc = WorktreesService::new();
11717 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11718 .await
11719 .unwrap();
11720 assert_eq!(
11722 svc.handle("heartbeat", json!({ "key": "w1" }))
11723 .await
11724 .unwrap(),
11725 json!({ "known": true })
11726 );
11727 svc.registry.mark_close_pending("w1");
11729 assert_eq!(
11730 svc.handle("heartbeat", json!({ "key": "w1" }))
11731 .await
11732 .unwrap(),
11733 json!({ "known": true, "close": true })
11734 );
11735 assert_eq!(
11736 svc.handle("heartbeat", json!({ "key": "w1" }))
11737 .await
11738 .unwrap(),
11739 json!({ "known": true })
11740 );
11741 }
11742
11743 #[tokio::test]
11746 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11747 let svc = WorktreesService::new();
11748 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11749 .await
11750 .unwrap();
11751 assert_eq!(
11754 svc.handle("heartbeat", json!({ "key": "w1" }))
11755 .await
11756 .unwrap(),
11757 json!({ "known": true })
11758 );
11759 svc.registry.mark_reload_pending("w1");
11761 assert_eq!(
11762 svc.handle("heartbeat", json!({ "key": "w1" }))
11763 .await
11764 .unwrap(),
11765 json!({ "known": true, "reload": true })
11766 );
11767 assert_eq!(
11768 svc.handle("heartbeat", json!({ "key": "w1" }))
11769 .await
11770 .unwrap(),
11771 json!({ "known": true })
11772 );
11773 }
11774
11775 #[tokio::test]
11776 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11777 let svc = WorktreesService::new();
11778 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11779 .await
11780 .unwrap();
11781 svc.registry.mark_close_pending("w1");
11785 svc.registry.mark_reload_pending("w1");
11786 assert_eq!(
11787 svc.handle("heartbeat", json!({ "key": "w1" }))
11788 .await
11789 .unwrap(),
11790 json!({ "known": true, "close": true, "reload": true })
11791 );
11792 assert_eq!(
11793 svc.handle("heartbeat", json!({ "key": "w1" }))
11794 .await
11795 .unwrap(),
11796 json!({ "known": true })
11797 );
11798 }
11799
11800 #[tokio::test]
11801 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11802 let svc = WorktreesService::new();
11803 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11804 .await
11805 .unwrap();
11806 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11807 .await
11808 .unwrap();
11809
11810 let reply = svc
11813 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11814 .await
11815 .unwrap();
11816 assert_eq!(
11817 reply,
11818 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11819 );
11820
11821 assert!(svc.registry.take_reload_pending("w1"));
11824 assert!(svc.registry.take_reload_pending("w2"));
11825 assert!(!svc.registry.take_reload_pending("ghost"));
11826 }
11827
11828 #[tokio::test]
11829 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11830 let svc = WorktreesService::new();
11831 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11832 .await
11833 .unwrap();
11834
11835 assert_eq!(
11838 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11839 .await
11840 .unwrap(),
11841 json!({ "requested": 1, "signalled": 1, "unknown": [] })
11842 );
11843
11844 assert_eq!(
11847 svc.handle("reload", json!({ "target_keys": [] }))
11848 .await
11849 .unwrap(),
11850 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11851 );
11852 assert_eq!(
11853 svc.handle("reload", json!({})).await.unwrap(),
11854 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11855 );
11856 }
11857
11858 #[tokio::test]
11859 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11860 let svc = WorktreesService::new();
11861 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11862 .await
11863 .unwrap();
11864 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11865 .await
11866 .unwrap();
11867
11868 svc.handle("reload", json!({ "target_keys": ["w2"] }))
11871 .await
11872 .unwrap();
11873 assert_eq!(
11874 svc.handle("heartbeat", json!({ "key": "w2" }))
11875 .await
11876 .unwrap(),
11877 json!({ "known": true, "reload": true })
11878 );
11879 assert_eq!(
11881 svc.handle("heartbeat", json!({ "key": "w1" }))
11882 .await
11883 .unwrap(),
11884 json!({ "known": true })
11885 );
11886 }
11887
11888 #[tokio::test]
11889 async fn reload_op_treats_an_unregistered_window_as_unknown() {
11890 let svc = WorktreesService::new();
11891 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11892 .await
11893 .unwrap();
11894 svc.handle("unregister", json!({ "key": "w1" }))
11895 .await
11896 .unwrap();
11897 assert_eq!(
11900 svc.handle("reload", json!({ "target_keys": ["w1"] }))
11901 .await
11902 .unwrap(),
11903 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11904 );
11905 }
11906
11907 #[tokio::test]
11908 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11909 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11910 let svc = Arc::new(WorktreesService::new());
11911 svc.handle(
11913 "register",
11914 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11915 )
11916 .await
11917 .unwrap();
11918
11919 let svc2 = svc.clone();
11922 let path = wt_path.clone();
11923 let close = tokio::spawn(async move {
11924 svc2.handle(
11925 "close",
11926 json!({
11927 "path": path,
11928 "remove": true,
11929 "confirmed": true,
11930 "requester_key": "w1",
11931 }),
11932 )
11933 .await
11934 });
11935
11936 let mut saw_close = false;
11939 for _ in 0..200 {
11940 let hb = svc
11941 .handle("heartbeat", json!({ "key": "w2" }))
11942 .await
11943 .unwrap();
11944 if hb.get("close").and_then(Value::as_bool) == Some(true) {
11945 saw_close = true;
11946 svc.handle("unregister", json!({ "key": "w2" }))
11947 .await
11948 .unwrap();
11949 break;
11950 }
11951 tokio::time::sleep(Duration::from_millis(5)).await;
11952 }
11953 assert!(saw_close, "w2 should have been told to close");
11954
11955 let reply = close.await.unwrap().unwrap();
11957 assert_eq!(reply, json!({ "removed": true }));
11958 assert!(!wt_path.exists());
11959 }
11960
11961 #[tokio::test]
11962 async fn await_windows_closed_times_out_when_a_window_never_closes() {
11963 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11964 let svc = WorktreesService::new();
11965 svc.handle(
11966 "register",
11967 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11968 )
11969 .await
11970 .unwrap();
11971 let err = await_windows_closed(
11974 &svc.registry,
11975 &wt_path,
11976 Some("w1"),
11977 Duration::from_millis(150),
11978 Duration::from_millis(25),
11979 )
11980 .await
11981 .unwrap_err();
11982 assert!(
11983 err.to_string().contains("w2"),
11984 "error names the window: {err}"
11985 );
11986 await_windows_closed(
11988 &svc.registry,
11989 &wt_path,
11990 Some("w2"),
11991 Duration::from_millis(150),
11992 Duration::from_millis(25),
11993 )
11994 .await
11995 .unwrap();
11996 }
11997
11998 #[tokio::test]
11999 async fn close_window_without_remove_replies_closed_and_never_deletes() {
12000 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12001 let svc = WorktreesService::new();
12002 let reply = svc
12004 .handle("close", json!({ "path": main.path(), "remove": false }))
12005 .await
12006 .unwrap();
12007 assert_eq!(reply, json!({ "closed": true }));
12008 assert!(main.path().exists());
12009 }
12010
12011 #[tokio::test]
12012 async fn close_safety_check_flags_modified_tracked_files() {
12013 let main_dir = tempfile::tempdir().unwrap();
12017 let repo = init_repo(main_dir.path());
12018 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12019 repo.set_head("refs/heads/trunk").unwrap();
12020 let wt_parent = tempfile::tempdir().unwrap();
12021 let wt_path = wt_parent.path().join("feature-wt");
12022 add_worktree(&repo, a, &wt_path, "feature");
12023 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12024
12025 let svc = WorktreesService::new();
12026 let report = svc
12027 .handle("close", json!({ "path": wt_path, "remove": true }))
12028 .await
12029 .unwrap();
12030 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12031 assert!(
12032 risks
12033 .iter()
12034 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12035 "expected a dirty risk: {report}"
12036 );
12037 }
12038
12039 #[tokio::test]
12040 async fn close_safety_check_flags_an_in_progress_operation() {
12041 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12044 let wt_repo = Repository::open(&wt_path).unwrap();
12045 let head = wt_repo.head().unwrap().target().unwrap();
12046 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12047 assert_ne!(wt_repo.state(), RepositoryState::Clean);
12048
12049 let svc = WorktreesService::new();
12050 let report = svc
12051 .handle("close", json!({ "path": wt_path, "remove": true }))
12052 .await
12053 .unwrap();
12054 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12055 assert!(
12056 risks
12057 .iter()
12058 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12059 "expected an in-progress risk: {report}"
12060 );
12061 }
12062
12063 #[tokio::test]
12064 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12065 let main_dir = tempfile::tempdir().unwrap();
12069 let repo = init_repo(main_dir.path());
12070 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12071 repo.set_head("refs/heads/trunk").unwrap();
12072 let a_commit = repo.find_commit(a).unwrap();
12073 repo.branch("feature", &a_commit, false).unwrap();
12074 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12075 .unwrap();
12076 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12078 drop(a_commit);
12079 let mut cfg = repo.config().unwrap();
12080 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12081 .unwrap();
12082 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12083 .unwrap();
12084 cfg.set_str("branch.feature.remote", "origin").unwrap();
12085 cfg.set_str("branch.feature.merge", "refs/heads/feature")
12086 .unwrap();
12087 let wt_parent = tempfile::tempdir().unwrap();
12090 let wt_path = wt_parent.path().join("feature-wt");
12091 let reference = repo.find_reference("refs/heads/feature").unwrap();
12092 let mut opts = git2::WorktreeAddOptions::new();
12093 opts.reference(Some(&reference));
12094 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12095
12096 let svc = WorktreesService::new();
12097 let report = svc
12098 .handle("close", json!({ "path": wt_path, "remove": true }))
12099 .await
12100 .unwrap();
12101 let info = report.get("info").and_then(Value::as_array).unwrap();
12104 assert!(
12105 info.iter()
12106 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12107 "expected an unpushed info note: {report}"
12108 );
12109 assert!(
12110 report
12111 .get("risks")
12112 .and_then(Value::as_array)
12113 .unwrap()
12114 .is_empty(),
12115 "unpushed commits alone must not block: {report}"
12116 );
12117 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12118 }
12119
12120 #[tokio::test]
12121 async fn close_safety_check_ignores_gitignored_files() {
12122 let main_dir = tempfile::tempdir().unwrap();
12126 let repo = init_repo(main_dir.path());
12127 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12128 repo.set_head("refs/heads/trunk").unwrap();
12129 let wt_parent = tempfile::tempdir().unwrap();
12130 let wt_path = wt_parent.path().join("feature-wt");
12131 add_worktree(&repo, a, &wt_path, "feature");
12132 std::fs::create_dir(wt_path.join("build")).unwrap();
12133 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12134
12135 let svc = WorktreesService::new();
12136 let report = svc
12137 .handle("close", json!({ "path": wt_path, "remove": true }))
12138 .await
12139 .unwrap();
12140 assert!(
12141 report
12142 .get("risks")
12143 .and_then(Value::as_array)
12144 .unwrap()
12145 .is_empty(),
12146 "a gitignored file must not create a risk: {report}"
12147 );
12148 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12149 }
12150
12151 #[tokio::test]
12152 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12153 let svc = WorktreesService::new();
12156 let report = svc
12157 .handle(
12158 "close",
12159 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12160 )
12161 .await
12162 .unwrap();
12163 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12164 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12165 assert!(report
12166 .get("risks")
12167 .and_then(Value::as_array)
12168 .unwrap()
12169 .is_empty());
12170 let info = report.get("info").and_then(Value::as_array).unwrap();
12171 assert!(info
12172 .iter()
12173 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12174 }
12175
12176 #[tokio::test]
12177 async fn close_phase1_errors_on_a_non_git_worktree_path() {
12178 let dir = tempfile::tempdir().unwrap();
12183 let svc = WorktreesService::new();
12184 let result = svc
12185 .handle("close", json!({ "path": dir.path(), "remove": true }))
12186 .await;
12187 assert!(
12188 result.is_err(),
12189 "a non-git-worktree target must error the safety check, got: {result:?}"
12190 );
12191 }
12192
12193 #[tokio::test]
12194 async fn close_refuses_a_locked_worktree() {
12195 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12198 let main_repo = Repository::open(main.path()).unwrap();
12199 main_repo
12200 .find_worktree("feature")
12201 .unwrap()
12202 .lock(Some("under test"))
12203 .unwrap();
12204
12205 let svc = WorktreesService::new();
12206 let err = svc
12207 .handle(
12208 "close",
12209 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12210 )
12211 .await
12212 .unwrap_err();
12213 assert!(
12214 err.to_string().contains("locked"),
12215 "expected a locked error: {err}"
12216 );
12217 assert!(wt_path.exists(), "a locked worktree must not be removed");
12218 }
12219
12220 #[tokio::test]
12221 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12222 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12226 let wt_repo = Repository::open(&wt_path).unwrap();
12227 let tip = wt_repo.head().unwrap().target().unwrap();
12230 wt_repo.set_head_detached(tip).unwrap();
12231 assert!(wt_repo.head_detached().unwrap());
12232
12233 let svc = WorktreesService::new();
12234 let report = svc
12235 .handle("close", json!({ "path": wt_path, "remove": true }))
12236 .await
12237 .unwrap();
12238 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12239 assert!(
12240 !risks
12241 .iter()
12242 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12243 "a detached HEAD reachable from a branch must not be flagged: {report}"
12244 );
12245 }
12246
12247 #[test]
12248 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12249 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12250 let main_repo = Repository::open(main.path()).unwrap();
12251 assert_eq!(
12253 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12254 "feature"
12255 );
12256 let err =
12259 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12260 assert!(
12261 err.to_string().contains("not registered"),
12262 "expected a not-registered error: {err}"
12263 );
12264 }
12265
12266 #[test]
12267 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12268 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12271 let repo = Repository::open(&wt_path).unwrap();
12272 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12273 assert!(
12276 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12277 "a corrupt index should make statuses() fail"
12278 );
12279 assert_eq!(count_dirty_untracked(&repo), (0, 0));
12280 }
12281}