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::{retry_on_etxtbsy, shim_lock, write_exec_script};
5127 use std::sync::MutexGuard;
5128
5129 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5130 json!({
5131 "key": key,
5132 "folders": [folder],
5133 "repo": repo,
5134 "title": format!("{key}-title"),
5135 "pid": 1234,
5136 })
5137 }
5138
5139 fn windows_of(payload: &Value) -> &Vec<Value> {
5141 payload
5142 .get("windows")
5143 .and_then(Value::as_array)
5144 .expect("windows array")
5145 }
5146
5147 #[tokio::test]
5148 async fn name_and_unknown_op() {
5149 let svc = WorktreesService::new();
5150 assert_eq!(svc.name(), "worktrees");
5151 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5152 }
5153
5154 #[tokio::test]
5155 async fn handle_routes_ops_and_shapes_payloads() {
5156 let svc = WorktreesService::new();
5157 let payload = svc.handle("list", Value::Null).await.unwrap();
5159 assert_eq!(payload, json!({ "windows": [] }));
5160
5161 let reply = svc
5163 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5164 .await
5165 .unwrap();
5166 assert_eq!(reply, json!({ "ok": true }));
5167 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5168 assert_eq!(windows.len(), 1);
5169 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5170 assert!(windows[0].get("last_seen").is_some());
5171
5172 let known = svc
5174 .handle("heartbeat", json!({ "key": "w1" }))
5175 .await
5176 .unwrap();
5177 assert_eq!(known, json!({ "known": true }));
5178 let unknown = svc
5179 .handle("heartbeat", json!({ "key": "nope" }))
5180 .await
5181 .unwrap();
5182 assert_eq!(unknown, json!({ "known": false }));
5183
5184 let reloaded = svc
5186 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5187 .await
5188 .unwrap();
5189 assert_eq!(
5190 reloaded,
5191 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5192 );
5193 assert!(svc.registry.take_reload_pending("w1"));
5194
5195 let gone = svc
5197 .handle("unregister", json!({ "key": "w1" }))
5198 .await
5199 .unwrap();
5200 assert_eq!(gone, json!({ "removed": true }));
5201 let again = svc
5202 .handle("unregister", json!({ "key": "w1" }))
5203 .await
5204 .unwrap();
5205 assert_eq!(again, json!({ "removed": false }));
5206 }
5207
5208 #[derive(Clone)]
5220 struct StubBackend {
5221 trusted: bool,
5222 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5225 writes: Arc<Mutex<Vec<geometry::Frame>>>,
5227 }
5228
5229 impl StubBackend {
5230 fn new(trusted: bool) -> Self {
5233 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5234 title: title.to_string(),
5235 frame: geometry::Frame {
5236 x,
5237 y: 0.0,
5238 width,
5239 height: 600.0,
5240 },
5241 minimized: false,
5242 fullscreen: false,
5243 standard: true,
5244 focused: false,
5245 };
5246 Self {
5247 trusted,
5248 windows: Arc::new(Mutex::new(vec![
5249 window("plan.md — ref-tree", 0.0, 800.0),
5250 window("main.rs — other-tree", 900.0, 500.0),
5251 ])),
5252 writes: Arc::new(Mutex::new(Vec::new())),
5253 }
5254 }
5255
5256 fn writes(&self) -> Vec<geometry::Frame> {
5257 self.writes
5258 .lock()
5259 .unwrap_or_else(PoisonError::into_inner)
5260 .clone()
5261 }
5262
5263 fn frame_of(&self, index: usize) -> geometry::Frame {
5265 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5266 }
5267
5268 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5270 let clone = self.clone();
5271 move || clone
5272 }
5273 }
5274
5275 impl geometry::WindowBackend for StubBackend {
5276 fn trusted(&self) -> bool {
5277 self.trusted
5278 }
5279
5280 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5281 pids.iter()
5282 .filter(|p| **p == 11 || **p == 12)
5283 .map(|p| (*p, 900))
5284 .collect()
5285 }
5286
5287 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5288 if app_pid != 900 {
5289 return Ok(Vec::new());
5290 }
5291 Ok(self
5292 .windows
5293 .lock()
5294 .unwrap_or_else(PoisonError::into_inner)
5295 .clone())
5296 }
5297
5298 fn set_frame(
5299 &self,
5300 id: geometry::WindowId,
5301 frame: geometry::Frame,
5302 ) -> Result<geometry::Frame, String> {
5303 self.writes
5304 .lock()
5305 .unwrap_or_else(PoisonError::into_inner)
5306 .push(frame);
5307 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5308 let window = windows
5309 .get_mut(id.index)
5310 .ok_or_else(|| format!("no window at index {}", id.index))?;
5311 window.frame = frame;
5312 Ok(frame)
5313 }
5314 }
5315
5316 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5319 svc.registry.register(
5320 serde_json::from_value(json!({
5321 "key": key,
5322 "folders": [format!("/tmp/{key}")],
5323 "title": title,
5324 "pid": pid,
5325 }))
5326 .expect("valid register payload"),
5327 );
5328 }
5329
5330 #[tokio::test]
5331 async fn reposition_requires_a_resolvable_reference() {
5332 let svc = WorktreesService::new();
5333 assert!(svc.handle("reposition", json!({})).await.is_err());
5336 assert!(svc
5337 .handle("reposition", json!({ "reference_key": " " }))
5338 .await
5339 .is_err());
5340 assert!(svc
5341 .handle("reposition", json!({ "reference_key": "ghost" }))
5342 .await
5343 .is_err());
5344 }
5345
5346 #[tokio::test]
5347 async fn reposition_moves_targets_and_records_an_undo() {
5348 let svc = WorktreesService::new();
5349 register_window(&svc, "ref", "ref-tree", 11);
5350 register_window(&svc, "other", "other-tree", 12);
5351 let backend = StubBackend::new(true);
5352
5353 let reply = svc
5354 .reposition_with(
5355 serde_json::from_value(json!({
5356 "reference_key": "ref",
5357 "target_keys": ["other"],
5358 }))
5359 .unwrap(),
5360 backend.factory(),
5361 )
5362 .await
5363 .unwrap();
5364
5365 assert_eq!(reply["trusted"], json!(true));
5366 assert_eq!(reply["moved"], json!(1));
5367 assert_eq!(reply["skipped"], json!(0));
5368 assert_eq!(reply["undoable"], json!(true));
5369 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5370 assert_eq!(reply["results"][0]["key"], json!("other"));
5371 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5372 assert_eq!(backend.writes().len(), 1);
5374 assert_eq!(
5375 backend.writes()[0],
5376 backend.frame_of(0),
5377 "wrote the reference window's own frame"
5378 );
5379 assert_eq!(
5380 backend.frame_of(1),
5381 backend.frame_of(0),
5382 "the target now occupies the reference's frame"
5383 );
5384
5385 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5389 assert_eq!(undone["moved"], json!(1));
5390 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5391 assert!(undone.get("reference").is_none(), "undo has no reference");
5392 assert_eq!(
5393 backend.frame_of(1),
5394 geometry::Frame {
5395 x: 900.0,
5396 y: 0.0,
5397 width: 500.0,
5398 height: 600.0,
5399 },
5400 "restored to exactly the pre-move frame"
5401 );
5402
5403 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5404 assert_eq!(
5405 again,
5406 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5407 );
5408 }
5409
5410 #[tokio::test]
5411 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5412 let svc = WorktreesService::new();
5413 register_window(&svc, "ref", "ref-tree", 11);
5414 register_window(&svc, "other", "other-tree", 12);
5415 let backend = StubBackend::new(true);
5416
5417 let reply = svc
5418 .reposition_with(
5419 serde_json::from_value(json!({
5420 "reference_key": "ref",
5421 "target_keys": ["other"],
5422 "check": true,
5423 }))
5424 .unwrap(),
5425 backend.factory(),
5426 )
5427 .await
5428 .unwrap();
5429
5430 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5431 assert!(
5432 reply.get("undoable").is_none(),
5433 "a dry run leaves nothing to undo"
5434 );
5435 assert!(
5436 backend.writes().is_empty(),
5437 "a dry run must not touch a window"
5438 );
5439 }
5440
5441 #[tokio::test]
5442 async fn reposition_reports_a_missing_permission_as_data() {
5443 let svc = WorktreesService::new();
5444 register_window(&svc, "ref", "ref-tree", 11);
5445 register_window(&svc, "other", "other-tree", 12);
5446 let backend = StubBackend::new(false);
5447
5448 let reply = svc
5449 .reposition_with(
5450 serde_json::from_value(json!({
5451 "reference_key": "ref",
5452 "target_keys": ["other"],
5453 }))
5454 .unwrap(),
5455 backend.factory(),
5456 )
5457 .await
5458 .unwrap();
5459
5460 assert_eq!(reply["trusted"], json!(false));
5463 assert_eq!(reply["moved"], json!(0));
5464 assert!(backend.writes().is_empty());
5465 }
5466
5467 #[tokio::test]
5468 async fn a_stale_target_key_is_skipped_not_fatal() {
5469 let svc = WorktreesService::new();
5470 register_window(&svc, "ref", "ref-tree", 11);
5471 register_window(&svc, "other", "other-tree", 12);
5472 let backend = StubBackend::new(true);
5473
5474 let reply = svc
5475 .reposition_with(
5476 serde_json::from_value(json!({
5477 "reference_key": "ref",
5478 "target_keys": ["closed-since", "ref", "other"],
5481 }))
5482 .unwrap(),
5483 backend.factory(),
5484 )
5485 .await
5486 .unwrap();
5487
5488 let outcomes: Vec<&str> = reply["results"]
5489 .as_array()
5490 .unwrap()
5491 .iter()
5492 .map(|r| r["outcome"].as_str().unwrap())
5493 .collect();
5494 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5495 assert_eq!(reply["moved"], json!(1));
5496 assert_eq!(reply["skipped"], json!(2));
5497 }
5498
5499 #[tokio::test]
5500 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5501 let svc = WorktreesService::new();
5502 register_window(&svc, "ref", "twin", 11);
5505 register_window(&svc, "other", "other-tree", 12);
5506 let backend = StubBackend::new(true);
5509 {
5510 let mut windows = backend
5511 .windows
5512 .lock()
5513 .unwrap_or_else(PoisonError::into_inner);
5514 windows[0].title = "a.rs — twin".to_string();
5515 windows[1].title = "b.rs — twin".to_string();
5516 }
5517
5518 let reply = svc
5519 .reposition_with(
5520 serde_json::from_value(json!({
5521 "reference_key": "ref",
5522 "target_keys": ["other"],
5523 }))
5524 .unwrap(),
5525 backend.factory(),
5526 )
5527 .await
5528 .unwrap();
5529
5530 assert_eq!(reply["trusted"], json!(true));
5531 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5532 assert!(
5533 reply["blocked"]["detail"]
5534 .as_str()
5535 .is_some_and(|d| d.contains("twin")),
5536 "the reason should name the ambiguous title: {reply}"
5537 );
5538 assert_eq!(reply["results"], json!([]), "no target is attempted");
5539 assert!(reply.get("undoable").is_none());
5540 assert!(backend.writes().is_empty());
5541
5542 let undone = svc
5544 .reposition_undo_with(StubBackend::new(true).factory())
5545 .await
5546 .unwrap();
5547 assert_eq!(undone["moved"], json!(0));
5548 }
5549
5550 #[tokio::test]
5551 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5552 let svc = WorktreesService::new();
5553 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5554 assert_eq!(reply["moved"], json!(0));
5555 assert_eq!(reply["results"], json!([]));
5556 }
5557
5558 #[test]
5559 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5560 let empty = geometry::RepositionReport {
5561 trusted: true,
5562 blocked: None,
5563 reference: None,
5564 results: Vec::new(),
5565 undo: Vec::new(),
5566 };
5567 assert_eq!(outcome_kinds(&empty), "-");
5568 }
5569
5570 #[tokio::test]
5571 async fn handle_rejects_missing_or_empty_key() {
5572 let svc = WorktreesService::new();
5573 assert!(svc.handle("register", json!({})).await.is_err());
5575 assert!(svc
5576 .handle("register", json!({ "key": " " }))
5577 .await
5578 .is_err());
5579 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5581 assert!(svc.handle("unregister", json!({})).await.is_err());
5582 }
5583
5584 #[test]
5585 fn display_name_prefers_repo_then_folder_basename() {
5586 let base = WindowEntry {
5587 key: "k".to_string(),
5588 folders: vec![PathBuf::from("/home/me/project")],
5589 repo: Some("my-repo".to_string()),
5590 title: None,
5591 pid: None,
5592 last_seen: Utc::now(),
5593 };
5594 assert_eq!(display_name(&base), "my-repo");
5595
5596 let no_repo = WindowEntry {
5597 repo: None,
5598 ..base.clone()
5599 };
5600 assert_eq!(display_name(&no_repo), "project");
5601
5602 let nothing = WindowEntry {
5603 repo: None,
5604 folders: vec![],
5605 ..base.clone()
5606 };
5607 assert_eq!(display_name(¬hing), "(no folder)");
5608
5609 let rootish = WindowEntry {
5612 repo: None,
5613 folders: vec![PathBuf::from("/")],
5614 ..base
5615 };
5616 assert_eq!(display_name(&rootish), "/");
5617 }
5618
5619 #[test]
5620 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5621 let now = Utc::now();
5622 let entries = vec![
5623 WindowEntry {
5628 key: "k2".to_string(),
5629 folders: vec![],
5630 repo: Some("solo".to_string()),
5631 title: Some("solo".to_string()),
5632 pid: None,
5633 last_seen: now,
5634 },
5635 WindowEntry {
5638 key: "k1".to_string(),
5639 folders: vec![PathBuf::from("/tmp/a")],
5640 repo: Some("repo".to_string()),
5641 title: Some("a branch".to_string()),
5642 pid: None,
5643 last_seen: now,
5644 },
5645 ];
5646 let items = window_menu_items(&entries);
5647 assert_eq!(items.len(), 2);
5649 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5650
5651 let action = items
5654 .iter()
5655 .find_map(|i| match i {
5656 MenuItem::Action(a) => Some(a),
5657 _ => None,
5658 })
5659 .expect("a focus action");
5660 assert_eq!(action.id, "focus:k1");
5661 assert_eq!(action.label, "repo · a branch");
5662
5663 let labels: Vec<&str> = items
5665 .iter()
5666 .filter_map(|i| match i {
5667 MenuItem::Label(t) => Some(t.as_str()),
5668 _ => None,
5669 })
5670 .collect();
5671 assert_eq!(labels, vec!["solo"]);
5672 }
5673
5674 #[tokio::test]
5675 async fn menu_and_status_shapes() {
5676 let svc = WorktreesService::new();
5677 let menu = svc.menu();
5679 assert_eq!(menu.title, "Worktrees");
5680 assert!(matches!(
5681 menu.items.first(),
5682 Some(MenuItem::Label(text)) if text == "No open windows"
5683 ));
5684 let status = svc.status().await;
5685 assert_eq!(status.name, "worktrees");
5686 assert!(status.healthy);
5687 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5688
5689 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5692 .await
5693 .unwrap();
5694 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5695 .await
5696 .unwrap();
5697 svc.handle(
5698 "register",
5699 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5700 )
5701 .await
5702 .unwrap();
5703 let status = svc.status().await;
5704 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5705
5706 let menu = svc.menu();
5707 assert_eq!(menu.items.len(), 3);
5709 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5710 let action_ids: Vec<&str> = menu
5711 .items
5712 .iter()
5713 .filter_map(|i| match i {
5714 MenuItem::Action(a) => Some(a.id.as_str()),
5715 _ => None,
5716 })
5717 .collect();
5718 assert!(action_ids.contains(&"focus:w1"));
5721 assert!(action_ids.contains(&"focus:w2"));
5722 assert!(!action_ids.contains(&"focus:w3"));
5723 }
5724
5725 #[test]
5726 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5727 let svc = WorktreesService::new();
5730 svc.start_menu_refresh();
5731 assert!(svc.refresh.lock().unwrap().is_none());
5732 }
5733
5734 #[tokio::test]
5735 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5736 let svc = WorktreesService::new();
5737 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5738 .await
5739 .unwrap();
5740 assert!(svc.menu_cache.lock().unwrap().is_none());
5742
5743 svc.start_menu_refresh();
5744 svc.start_menu_refresh();
5746
5747 let mut filled = false;
5749 for _ in 0..100 {
5750 if svc.menu_cache.lock().unwrap().is_some() {
5751 filled = true;
5752 break;
5753 }
5754 tokio::time::sleep(Duration::from_millis(10)).await;
5755 }
5756 assert!(filled, "background refresh should populate the menu cache");
5757
5758 let menu = svc.menu();
5760 assert_eq!(menu.title, "Worktrees");
5761 assert!(menu
5762 .items
5763 .iter()
5764 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5765
5766 svc.shutdown().await;
5768 assert!(svc.refresh.lock().unwrap().is_none());
5769 }
5770
5771 #[tokio::test]
5772 async fn default_constructs_an_empty_service() {
5773 let svc = WorktreesService::default();
5774 let payload = svc.handle("list", Value::Null).await.unwrap();
5775 assert_eq!(payload, json!({ "windows": [] }));
5776 }
5777
5778 #[tokio::test]
5781 async fn subscribe_streams_only_for_the_subscribe_op() {
5782 let svc = WorktreesService::new();
5783 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5787 assert!(svc.subscribe("list", &Value::Null).is_none());
5788 assert!(svc.subscribe("register", &Value::Null).is_none());
5789 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5790 }
5791
5792 #[tokio::test]
5793 async fn subscribe_snapshot_matches_the_tree_op() {
5794 let dir = tempfile::tempdir().unwrap();
5795 let repo = init_repo(dir.path());
5796 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5797 repo.set_head("refs/heads/main").unwrap();
5798
5799 let svc = WorktreesService::new();
5800 let stream = svc
5801 .subscribe("subscribe", &Value::Null)
5802 .expect("subscribe stream");
5803 assert_eq!(
5806 stream.snapshot().await,
5807 json!({ "repos": [], "show_closed": true })
5808 );
5809
5810 svc.handle(
5813 "register",
5814 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5815 )
5816 .await
5817 .unwrap();
5818 let snap = stream.snapshot().await;
5819 let tree = svc.handle("tree", Value::Null).await.unwrap();
5820 assert_eq!(snap, tree);
5821 let repos = snap["repos"].as_array().expect("repos array");
5822 assert_eq!(repos.len(), 1);
5823 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5824 }
5825
5826 #[tokio::test]
5827 async fn subscribe_changed_wakes_on_register() {
5828 let svc = WorktreesService::new();
5829 let mut stream = svc
5830 .subscribe("subscribe", &Value::Null)
5831 .expect("subscribe stream");
5832 tokio::select! {
5834 () = stream.changed() => panic!("changed resolved with no registry change"),
5835 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5836 }
5837 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5839 .await
5840 .unwrap();
5841 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5842 .await
5843 .expect("changed should resolve after a register");
5844 }
5845
5846 #[tokio::test]
5849 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5850 let reg = Arc::new(WorktreesRegistry::new());
5851 let cache = TreeSnapshotCache::with_ttl(
5853 reg,
5854 Arc::new(PrStatusCache::new()),
5855 Duration::from_secs(60),
5856 );
5857 let first = cache.snapshot().await;
5859 assert_eq!(cache.compute_count(), 1);
5860 let second = cache.snapshot().await;
5863 assert_eq!(
5864 cache.compute_count(),
5865 1,
5866 "an unchanged read must not rebuild"
5867 );
5868 assert_eq!(first, second);
5869 }
5870
5871 #[tokio::test]
5872 async fn tree_cache_single_flights_a_read_burst() {
5873 let reg = Arc::new(WorktreesRegistry::new());
5874 let cache = Arc::new(TreeSnapshotCache::with_ttl(
5875 reg,
5876 Arc::new(PrStatusCache::new()),
5877 Duration::from_secs(60),
5878 ));
5879 let mut handles = Vec::new();
5883 for _ in 0..16 {
5884 let cache = cache.clone();
5885 handles.push(tokio::spawn(async move { cache.snapshot().await }));
5886 }
5887 let mut results = Vec::new();
5888 for handle in handles {
5889 results.push(handle.await.unwrap());
5890 }
5891 assert_eq!(
5892 cache.compute_count(),
5893 1,
5894 "a concurrent read burst must build the tree once"
5895 );
5896 assert!(
5897 results.windows(2).all(|w| w[0] == w[1]),
5898 "every reader must observe the identical snapshot"
5899 );
5900 }
5901
5902 #[tokio::test]
5903 async fn tree_cache_rebuilds_on_registry_change() {
5904 let reg = Arc::new(WorktreesRegistry::new());
5905 let cache = TreeSnapshotCache::with_ttl(
5906 reg.clone(),
5907 Arc::new(PrStatusCache::new()),
5908 Duration::from_secs(60),
5909 );
5910 cache.snapshot().await;
5911 assert_eq!(cache.compute_count(), 1);
5912 assert!(reg.set_show_closed(false));
5916 cache.snapshot().await;
5917 assert_eq!(
5918 cache.compute_count(),
5919 2,
5920 "a generation bump must force a rebuild"
5921 );
5922 }
5923
5924 #[tokio::test]
5925 async fn tree_cache_rebuilds_after_ttl_expiry() {
5926 let reg = Arc::new(WorktreesRegistry::new());
5927 let cache =
5930 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5931 cache.snapshot().await;
5932 cache.snapshot().await;
5933 assert_eq!(
5934 cache.compute_count(),
5935 2,
5936 "an expired TTL must force a rebuild each read"
5937 );
5938 }
5939
5940 #[tokio::test]
5941 async fn subscribe_streams_share_one_build_per_generation() {
5942 let svc = WorktreesService::new();
5943 let s1 = svc
5944 .subscribe("subscribe", &Value::Null)
5945 .expect("subscribe stream");
5946 let s2 = svc
5947 .subscribe("subscribe", &Value::Null)
5948 .expect("subscribe stream");
5949 let a = s1.snapshot().await;
5952 let b = s2.snapshot().await;
5953 assert_eq!(a, b);
5954 assert_eq!(
5955 svc.tree_cache.compute_count(),
5956 1,
5957 "N streams on one generation must share a single build"
5958 );
5959 }
5960
5961 #[tokio::test]
5964 async fn set_show_closed_toggles_the_snapshot_field() {
5965 let svc = WorktreesService::new();
5966 assert_eq!(
5968 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5969 json!(true)
5970 );
5971 let reply = svc
5973 .handle("set-show-closed", json!({ "show_closed": false }))
5974 .await
5975 .unwrap();
5976 assert_eq!(reply, json!({ "ok": true }));
5977 assert_eq!(
5978 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5979 json!(false)
5980 );
5981 }
5982
5983 #[tokio::test]
5984 async fn set_show_closed_rejects_a_non_boolean_payload() {
5985 let svc = WorktreesService::new();
5986 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5987 assert!(svc
5988 .handle("set-show-closed", json!({ "show_closed": "yes" }))
5989 .await
5990 .is_err());
5991 }
5992
5993 #[tokio::test]
5994 async fn set_show_closed_wakes_the_subscription() {
5995 let svc = WorktreesService::new();
5996 let mut stream = svc
5997 .subscribe("subscribe", &Value::Null)
5998 .expect("subscribe stream");
5999 svc.handle("set-show-closed", json!({ "show_closed": false }))
6001 .await
6002 .unwrap();
6003 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6004 .await
6005 .expect("changed should resolve after a toggle flip");
6006 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6008 }
6009
6010 #[tokio::test]
6011 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6012 let dir = tempfile::tempdir().unwrap();
6015 github_repo(dir.path());
6016 let svc = WorktreesService::new();
6017 svc.handle(
6018 "register",
6019 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6020 )
6021 .await
6022 .unwrap();
6023
6024 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6025 assert!(
6026 repo.get("polling_enabled").is_none(),
6027 "default off omits the flag: {repo:?}"
6028 );
6029
6030 let reply = svc
6031 .handle(
6032 "set-polling",
6033 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6034 )
6035 .await
6036 .unwrap();
6037 assert_eq!(reply, json!({ "ok": true }));
6038 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6039 assert_eq!(repo["polling_enabled"], json!(true));
6040
6041 svc.handle(
6042 "set-polling",
6043 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6044 )
6045 .await
6046 .unwrap();
6047 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6048 assert!(repo.get("polling_enabled").is_none());
6049 }
6050
6051 #[tokio::test]
6052 async fn set_polling_rejects_missing_or_empty_fields() {
6053 let svc = WorktreesService::new();
6054 assert!(svc
6056 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6057 .await
6058 .is_err());
6059 assert!(svc
6061 .handle("set-polling", json!({ "enabled": true }))
6062 .await
6063 .is_err());
6064 assert!(svc
6066 .handle(
6067 "set-polling",
6068 json!({ "owner": " ", "name": "n", "enabled": true })
6069 )
6070 .await
6071 .is_err());
6072 }
6073
6074 #[tokio::test]
6075 async fn set_polling_wakes_the_subscription() {
6076 let dir = tempfile::tempdir().unwrap();
6077 github_repo(dir.path());
6078 let svc = WorktreesService::new();
6079 svc.handle(
6080 "register",
6081 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6082 )
6083 .await
6084 .unwrap();
6085 let mut stream = svc
6086 .subscribe("subscribe", &Value::Null)
6087 .expect("subscribe stream");
6088 svc.handle(
6089 "set-polling",
6090 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6091 )
6092 .await
6093 .unwrap();
6094 tokio::time::timeout(Duration::from_secs(1), stream.changed())
6095 .await
6096 .expect("changed should resolve after enabling a repo");
6097 let repo = repos_of(&stream.snapshot().await)[0].clone();
6098 assert_eq!(repo["polling_enabled"], json!(true));
6099 }
6100
6101 #[tokio::test]
6102 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6103 let dir = tempfile::tempdir().unwrap();
6107 let repo = github_repo(dir.path());
6108 let head = repo.head().unwrap().target().unwrap().to_string();
6109 let svc = WorktreesService::new();
6110 svc.handle(
6111 "register",
6112 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6113 )
6114 .await
6115 .unwrap();
6116 svc.registry.set_polling("rust-works", "omni-dev", true);
6117
6118 let mut badges = HashMap::new();
6119 badges.insert(
6120 PrTarget {
6121 owner: "rust-works".into(),
6122 name: "omni-dev".into(),
6123 branch: "main".into(),
6124 },
6125 pr(pending_badge(7, &head)),
6126 );
6127 svc.pr_cache.replace(badges);
6128
6129 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6130 assert_eq!(wt["pr"]["number"], json!(7));
6131
6132 svc.handle(
6133 "set-polling",
6134 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6135 )
6136 .await
6137 .unwrap();
6138 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6139 assert!(
6140 wt.get("pr").is_none(),
6141 "a disabled repo must carry no badge: {wt:?}"
6142 );
6143 }
6144
6145 #[tokio::test]
6146 async fn an_expired_lease_drops_the_flag_and_badges() {
6147 let dir = tempfile::tempdir().unwrap();
6151 let repo = github_repo(dir.path());
6152 let head = repo.head().unwrap().target().unwrap().to_string();
6153 let svc = WorktreesService::new();
6154 svc.handle(
6155 "register",
6156 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6157 )
6158 .await
6159 .unwrap();
6160 svc.registry.set_polling("rust-works", "omni-dev", true);
6161 let mut badges = HashMap::new();
6162 badges.insert(
6163 PrTarget {
6164 owner: "rust-works".into(),
6165 name: "omni-dev".into(),
6166 branch: "main".into(),
6167 },
6168 pr(pending_badge(7, &head)),
6169 );
6170 svc.pr_cache.replace(badges);
6171
6172 let snap = svc.handle("tree", Value::Null).await.unwrap();
6174 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6175 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6176 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6177
6178 svc.registry.set_polling_expiry(
6180 "rust-works",
6181 "omni-dev",
6182 Utc::now() - chrono::Duration::minutes(1),
6183 );
6184
6185 let snap = svc.handle("tree", Value::Null).await.unwrap();
6186 let repo0 = &repos_of(&snap)[0];
6187 assert!(
6188 repo0.get("polling_enabled").is_none(),
6189 "expired lease drops the flag: {repo0:?}"
6190 );
6191 assert!(
6192 repo0["worktrees"][0].get("pr").is_none(),
6193 "expired lease drops the badge"
6194 );
6195 assert!(
6196 pr_targets_from_snapshot(&snap).is_empty(),
6197 "the poller no longer watches an expired repo"
6198 );
6199 }
6200
6201 #[tokio::test]
6202 async fn polling_prefs_persist_across_reloads_with_0600() {
6203 let dir = tempfile::tempdir().unwrap();
6206 let prefs = dir.path().join("worktrees-polling.json");
6207
6208 let svc = WorktreesService::new();
6209 svc.load_polling_prefs(prefs.clone());
6210 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6211 svc.handle(
6212 "set-polling",
6213 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6214 )
6215 .await
6216 .unwrap();
6217 assert!(prefs.exists());
6218 #[cfg(unix)]
6219 {
6220 use std::os::unix::fs::PermissionsExt;
6221 assert_eq!(
6222 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6223 0o600
6224 );
6225 }
6226
6227 let svc2 = WorktreesService::new();
6229 svc2.load_polling_prefs(prefs.clone());
6230 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6231
6232 svc2.handle(
6234 "set-polling",
6235 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6236 )
6237 .await
6238 .unwrap();
6239 let svc3 = WorktreesService::new();
6240 svc3.load_polling_prefs(prefs);
6241 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6242 }
6243
6244 #[test]
6245 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6246 let dir = tempfile::tempdir().unwrap();
6251
6252 let corrupt = dir.path().join("worktrees-polling.json");
6254 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6255 let svc = WorktreesService::new();
6256 svc.load_polling_prefs(corrupt);
6257 assert!(svc.registry.enabled_polling_repos().is_empty());
6258
6259 let as_dir = dir.path().join("is-a-directory");
6261 std::fs::create_dir(&as_dir).unwrap();
6262 let svc2 = WorktreesService::new();
6263 svc2.load_polling_prefs(as_dir);
6264 assert!(svc2.registry.enabled_polling_repos().is_empty());
6265 }
6266
6267 #[tokio::test]
6268 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6269 let dir = tempfile::tempdir().unwrap();
6272 github_repo(dir.path());
6273 let bin_dir = tempfile::tempdir().unwrap();
6274 let marker = bin_dir.path().join("spawned");
6275 let fake = bin_dir.path().join("fake-gh");
6276 std::fs::write(
6277 &fake,
6278 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6279 )
6280 .unwrap();
6281 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6282 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6283 std::fs::set_permissions(&fake, perms).unwrap();
6284
6285 let svc = WorktreesService::new();
6286 svc.handle(
6287 "register",
6288 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6289 )
6290 .await
6291 .unwrap();
6292 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6294 tokio::time::sleep(Duration::from_millis(200)).await;
6295 svc.shutdown().await;
6296 assert!(
6297 !marker.exists(),
6298 "a registered-but-not-enabled repo must drive zero gh"
6299 );
6300 }
6301
6302 #[tokio::test]
6303 async fn menu_action_rejects_unknown_and_missing_window() {
6304 let svc = WorktreesService::new();
6305 assert!(svc.menu_action("bogus").await.is_err());
6306 assert!(svc.menu_action("focus:nope").await.is_err());
6308 svc.shutdown().await;
6309 }
6310
6311 struct VscodeBinGuard(Option<std::ffi::OsString>);
6318 impl Drop for VscodeBinGuard {
6319 fn drop(&mut self) {
6320 match self.0.take() {
6321 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6322 None => std::env::remove_var(VSCODE_BIN_ENV),
6323 }
6324 }
6325 }
6326
6327 #[tokio::test]
6328 async fn menu_action_focus_resolves_folder_and_spawns() {
6329 let dir = tempfile::tempdir().unwrap();
6330 let svc = WorktreesService::new();
6331 svc.handle(
6332 "register",
6333 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6334 )
6335 .await
6336 .unwrap();
6337
6338 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6341 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6342 svc.menu_action("focus:w1").await.unwrap();
6343 }
6344
6345 #[tokio::test]
6346 async fn open_rejects_missing_relative_or_nonexistent_path() {
6347 let svc = WorktreesService::new();
6348 assert!(svc.handle("open", json!({})).await.is_err());
6350 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6351 assert!(svc
6354 .handle("open", json!({ "path": "relative/dir" }))
6355 .await
6356 .is_err());
6357 assert!(svc
6358 .handle("open", json!({ "path": "-flag" }))
6359 .await
6360 .is_err());
6361 assert!(svc
6364 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6365 .await
6366 .is_err());
6367 svc.shutdown().await;
6368 }
6369
6370 #[tokio::test]
6371 async fn open_focuses_an_existing_absolute_dir() {
6372 let dir = tempfile::tempdir().unwrap();
6373 let svc = WorktreesService::new();
6374 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6379 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6380 let reply = svc
6381 .handle("open", json!({ "path": dir.path() }))
6382 .await
6383 .unwrap();
6384 assert_eq!(reply, json!({ "ok": true }));
6385 svc.shutdown().await;
6386 }
6387
6388 #[test]
6389 fn focus_window_with_validates_folder_then_spawns() {
6390 let dir = tempfile::tempdir().unwrap();
6391 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6393 assert!(
6394 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6395 );
6396 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6398 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6400 }
6401
6402 #[test]
6403 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6404 assert_eq!(
6406 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6407 PathBuf::from("/custom/code")
6408 );
6409 let existing = tempfile::NamedTempFile::new().unwrap();
6411 let existing_path = existing.path().to_str().unwrap();
6412 assert_eq!(
6413 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6414 PathBuf::from(existing_path)
6415 );
6416 assert_eq!(
6418 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6419 PathBuf::from("code")
6420 );
6421 let _ = resolve_code_binary();
6423 }
6424
6425 fn init_repo(dir: &Path) -> Repository {
6430 let repo = Repository::init(dir).unwrap();
6431 let mut cfg = repo.config().unwrap();
6432 cfg.set_str("user.name", "Test").unwrap();
6433 cfg.set_str("user.email", "test@example.com").unwrap();
6434 repo
6435 }
6436
6437 fn empty_commit(
6440 repo: &Repository,
6441 refname: Option<&str>,
6442 parents: &[&git2::Commit<'_>],
6443 msg: &str,
6444 ) -> git2::Oid {
6445 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6446 let tree = repo
6447 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6448 .unwrap();
6449 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6450 .unwrap()
6451 }
6452
6453 fn commit_file(
6458 repo: &Repository,
6459 refname: &str,
6460 name: &str,
6461 content: &[u8],
6462 msg: &str,
6463 ) -> git2::Oid {
6464 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6465 let blob = repo.blob(content).unwrap();
6466 let mut builder = repo.treebuilder(None).unwrap();
6467 builder.insert(name, blob, 0o100_644).unwrap();
6468 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6469 let parent = repo
6470 .refname_to_id(refname)
6471 .ok()
6472 .and_then(|oid| repo.find_commit(oid).ok());
6473 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6474 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6475 .unwrap()
6476 }
6477
6478 fn diverging_repo(dir: &Path) -> Repository {
6481 let repo = init_repo(dir);
6482 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6484 let a_commit = repo.find_commit(a).unwrap();
6485 let c = empty_commit(&repo, None, &[&a_commit], "C");
6487 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6488 .unwrap();
6489 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6491 drop(a_commit);
6493 repo.set_head("refs/heads/main").unwrap();
6494 let mut cfg = repo.config().unwrap();
6496 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6497 .unwrap();
6498 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6499 .unwrap();
6500 cfg.set_str("branch.main.remote", "origin").unwrap();
6501 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6502 repo
6503 }
6504
6505 fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6511 let repo = init_repo(dir);
6512 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6513 let a_commit = repo.find_commit(a).unwrap();
6514 let c = empty_commit(&repo, None, &[&a_commit], "C");
6515 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6516 .unwrap();
6517 drop(a_commit);
6518 repo.set_head("refs/heads/main").unwrap();
6519 repo
6520 }
6521
6522 #[test]
6523 fn git_status_reads_branch_and_ahead_behind() {
6524 let dir = tempfile::tempdir().unwrap();
6525 let _repo = diverging_repo(dir.path());
6526 let status = git_status(dir.path());
6527 assert_eq!(status.branch.as_deref(), Some("main"));
6528 assert_eq!(status.ahead, Some(1));
6529 assert_eq!(status.behind, Some(1));
6530 assert_eq!(
6532 status.main_repo.as_deref(),
6533 dir.path().file_name().and_then(|n| n.to_str())
6534 );
6535 assert!(!status.is_worktree);
6536 }
6537
6538 #[test]
6539 fn git_status_empty_repo_is_unborn() {
6540 let dir = tempfile::tempdir().unwrap();
6544 init_repo(dir.path());
6545 let status = git_status(dir.path());
6546 assert_eq!(status.branch, None);
6547 assert_eq!(status.head_sha, None);
6549 assert_eq!(status.ahead, None);
6550 assert_eq!(status.behind, None);
6551 assert_eq!(
6552 status.main_repo.as_deref(),
6553 dir.path().file_name().and_then(|n| n.to_str())
6554 );
6555 assert!(!status.is_worktree);
6556 }
6557
6558 #[test]
6559 fn git_status_no_upstream_reports_branch_only() {
6560 let dir = tempfile::tempdir().unwrap();
6561 let repo = init_repo(dir.path());
6562 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6563 repo.set_head("refs/heads/main").unwrap();
6564 let status = git_status(dir.path());
6565 assert_eq!(status.branch.as_deref(), Some("main"));
6566 assert_eq!(status.ahead, None);
6568 assert_eq!(status.behind, None);
6569 assert_eq!(status.upstream_sha, None);
6572 }
6573
6574 #[test]
6575 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6576 let plain = tempfile::tempdir().unwrap();
6578 assert_eq!(git_status(plain.path()), GitStatus::default());
6579
6580 let dir = tempfile::tempdir().unwrap();
6583 let repo = init_repo(dir.path());
6584 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6585 repo.set_head_detached(a).unwrap();
6586 let status = git_status(dir.path());
6587 assert_eq!(status.branch, None);
6588 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6591 assert_eq!(status.ahead, None);
6592 assert_eq!(status.behind, None);
6593 assert_eq!(status.upstream_sha, None);
6596 assert_eq!(
6597 status.main_repo.as_deref(),
6598 dir.path().file_name().and_then(|n| n.to_str())
6599 );
6600 assert!(!status.is_worktree);
6601 }
6602
6603 #[test]
6606 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6607 let dir = tempfile::tempdir().unwrap();
6611 let repo = diverging_repo(dir.path());
6612 let status = git_status_cheap(dir.path());
6613 assert_eq!(status.branch.as_deref(), Some("main"));
6614 assert_eq!(status.ahead, None);
6615 assert_eq!(status.behind, None);
6616 assert_eq!(
6617 status.main_repo.as_deref(),
6618 dir.path().file_name().and_then(|n| n.to_str())
6619 );
6620 let head = repo.head().unwrap().target().unwrap();
6623 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6624 }
6625
6626 #[test]
6629 fn git_status_head_sha_tracks_new_commits() {
6630 let dir = tempfile::tempdir().unwrap();
6636 let repo = init_repo(dir.path());
6637 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6638 repo.set_head("refs/heads/main").unwrap();
6639 let before = git_status_cheap(dir.path());
6640 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6641
6642 let head = repo.find_commit(a).unwrap();
6643 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6644 let after = git_status_cheap(dir.path());
6645 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6646 assert_ne!(before.head_sha, after.head_sha);
6647 assert_eq!(before.branch, after.branch);
6650 }
6651
6652 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6658 repo.reference("refs/remotes/origin/main", oid, true, "push")
6659 .unwrap();
6660 }
6661
6662 #[test]
6663 fn git_status_upstream_sha_tracks_a_push() {
6664 let dir = tempfile::tempdir().unwrap();
6671 let repo = diverging_repo(dir.path());
6672 let before = git_status(dir.path());
6673 assert_eq!(before.ahead, Some(1));
6674 assert_eq!(before.behind, Some(1));
6675
6676 let head = repo.head().unwrap().target().unwrap();
6677 simulate_push(&repo, head);
6678 let after = git_status(dir.path());
6679
6680 assert_eq!(
6682 after.upstream_sha.as_deref(),
6683 Some(head.to_string().as_str())
6684 );
6685 assert_ne!(before.upstream_sha, after.upstream_sha);
6686 assert_eq!(after.ahead, Some(0));
6687 assert_eq!(after.behind, Some(0));
6688 assert_eq!(before.branch, after.branch);
6692 assert_eq!(before.head_sha, after.head_sha);
6693 }
6694
6695 #[test]
6696 fn git_status_cheap_reports_upstream_sha() {
6697 let dir = tempfile::tempdir().unwrap();
6702 let repo = diverging_repo(dir.path());
6703 let status = git_status_cheap(dir.path());
6704 let upstream = repo
6705 .find_branch("origin/main", git2::BranchType::Remote)
6706 .unwrap()
6707 .get()
6708 .target()
6709 .unwrap();
6710 assert_eq!(
6711 status.upstream_sha.as_deref(),
6712 Some(upstream.to_string().as_str())
6713 );
6714 assert_eq!(status.ahead, None);
6715 assert_eq!(status.behind, None);
6716 }
6717
6718 #[test]
6719 fn folder_ahead_behind_computes_divergence_and_degrades() {
6720 let dir = tempfile::tempdir().unwrap();
6722 let _repo = diverging_repo(dir.path());
6723 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6724
6725 let no_up = tempfile::tempdir().unwrap();
6727 let repo = init_repo(no_up.path());
6728 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6729 repo.set_head("refs/heads/main").unwrap();
6730 assert_eq!(folder_ahead_behind(no_up.path()), None);
6731
6732 let detached = tempfile::tempdir().unwrap();
6734 let drepo = init_repo(detached.path());
6735 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6736 drepo.set_head_detached(a).unwrap();
6737 assert_eq!(folder_ahead_behind(detached.path()), None);
6738 let plain = tempfile::tempdir().unwrap();
6739 assert_eq!(folder_ahead_behind(plain.path()), None);
6740 }
6741
6742 #[test]
6745 fn folder_main_behind_computes_divergence_and_degrades() {
6746 let dir = tempfile::tempdir().unwrap();
6750 let _repo = behind_main_no_upstream_repo(dir.path());
6751 assert_eq!(folder_main_behind(dir.path()), Some(1));
6752
6753 let detached = tempfile::tempdir().unwrap();
6755 let drepo = init_repo(detached.path());
6756 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6757 drepo.set_head_detached(a).unwrap();
6758 assert_eq!(folder_main_behind(detached.path()), None);
6759 let plain = tempfile::tempdir().unwrap();
6760 assert_eq!(folder_main_behind(plain.path()), None);
6761 }
6762
6763 #[test]
6764 fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6765 let dir = tempfile::tempdir().unwrap();
6770 let _repo = diverging_repo(dir.path());
6771 assert_eq!(folder_main_behind(dir.path()), None);
6772 }
6773
6774 #[test]
6775 fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6776 let dir = tempfile::tempdir().unwrap();
6778 let repo = init_repo(dir.path());
6779 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6780 repo.set_head("refs/heads/main").unwrap();
6781 assert_eq!(folder_main_behind(dir.path()), None);
6782 }
6783
6784 #[test]
6785 fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6786 let dir = tempfile::tempdir().unwrap();
6787 let repo = init_repo(dir.path());
6788 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6789 let base_commit = repo.find_commit(base).unwrap();
6790
6791 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6793 let m1_commit = repo.find_commit(m1).unwrap();
6794 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6795 let m2_commit = repo.find_commit(m2).unwrap();
6796 let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6797 repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6798 .unwrap();
6799
6800 let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6803 repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6804 .unwrap();
6805 empty_commit(
6806 &repo,
6807 Some("refs/heads/feature"),
6808 &[&base_commit],
6809 "local-feature",
6810 );
6811 drop(base_commit);
6812 drop(m1_commit);
6813 drop(m2_commit);
6814
6815 repo.set_head("refs/heads/feature").unwrap();
6816 let mut cfg = repo.config().unwrap();
6817 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6818 .unwrap();
6819 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6820 .unwrap();
6821 cfg.set_str("branch.feature.remote", "origin").unwrap();
6822 cfg.set_str("branch.feature.merge", "refs/heads/feature")
6823 .unwrap();
6824
6825 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6826 assert_eq!(folder_main_behind(dir.path()), Some(3));
6827 }
6828
6829 #[tokio::test]
6830 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6831 let diverging = tempfile::tempdir().unwrap();
6832 let _d = diverging_repo(diverging.path());
6833 let no_up = tempfile::tempdir().unwrap();
6834 let repo = init_repo(no_up.path());
6835 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6836 repo.set_head("refs/heads/main").unwrap();
6837
6838 let svc = WorktreesService::new();
6839 let diverging_path = diverging.path().display().to_string();
6840 let no_up_path = no_up.path().display().to_string();
6841 let reply = svc
6842 .handle(
6843 "ahead-behind",
6844 json!({ "paths": [&diverging_path, &no_up_path] }),
6845 )
6846 .await
6847 .unwrap();
6848 let results = reply.get("results").unwrap();
6849 let d = results.get(diverging_path.as_str()).unwrap();
6853 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6854 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6855 assert!(d.get("main_behind").is_none(), "{d:?}");
6856 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6860
6861 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6863 assert_eq!(empty.get("results"), Some(&json!({})));
6864 }
6865
6866 #[tokio::test]
6867 async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6868 let dir = tempfile::tempdir().unwrap();
6869 let _repo = behind_main_no_upstream_repo(dir.path());
6870 let svc = WorktreesService::new();
6871 let path = dir.path().display().to_string();
6872 let reply = svc
6873 .handle("ahead-behind", json!({ "paths": [&path] }))
6874 .await
6875 .unwrap();
6876 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6877 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6878 assert!(entry.get("ahead").is_none(), "{entry:?}");
6880 assert!(entry.get("behind").is_none(), "{entry:?}");
6881 }
6882
6883 #[tokio::test]
6884 async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6885 let dir = tempfile::tempdir().unwrap();
6890 let repo = init_repo(dir.path());
6891 let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6892 let base_commit = repo.find_commit(base).unwrap();
6893
6894 let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6895 let m1_commit = repo.find_commit(m1).unwrap();
6896 let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6897 repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6898 .unwrap();
6899
6900 let r = empty_commit(
6901 &repo,
6902 Some("refs/heads/release"),
6903 &[&base_commit],
6904 "release",
6905 );
6906 repo.reference("refs/remotes/origin/release", r, true, "origin release")
6907 .unwrap();
6908 drop(base_commit);
6909 drop(m1_commit);
6910
6911 repo.set_head("refs/heads/release").unwrap();
6912 let mut cfg = repo.config().unwrap();
6913 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6914 .unwrap();
6915 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6916 .unwrap();
6917 cfg.set_str("branch.release.remote", "origin").unwrap();
6918 cfg.set_str("branch.release.merge", "refs/heads/release")
6919 .unwrap();
6920
6921 let svc = WorktreesService::new();
6922 let path = dir.path().display().to_string();
6923 let reply = svc
6924 .handle("ahead-behind", json!({ "paths": [&path] }))
6925 .await
6926 .unwrap();
6927 let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6928 assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6929 assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6930 assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6931 }
6932
6933 #[tokio::test]
6934 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6935 let dir = tempfile::tempdir().unwrap();
6937 let _repo = diverging_repo(dir.path());
6938 let svc = WorktreesService::new();
6939 svc.handle(
6940 "register",
6941 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6942 )
6943 .await
6944 .unwrap();
6945
6946 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6947 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6948 let main_wt = &worktrees[0];
6949 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6952 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6953 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6954 }
6955
6956 #[tokio::test]
6957 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6958 let dir = tempfile::tempdir().unwrap();
6963 let repo = init_repo(dir.path());
6964 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6965 repo.set_head("refs/heads/main").unwrap();
6966
6967 let svc = WorktreesService::new();
6968 svc.handle(
6969 "register",
6970 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6971 )
6972 .await
6973 .unwrap();
6974
6975 let before = svc.handle("tree", Value::Null).await.unwrap();
6976 let wt = &repos_of(&before)[0]["worktrees"][0];
6977 assert_eq!(
6978 wt.get("head_sha").and_then(Value::as_str),
6979 Some(a.to_string().as_str())
6980 );
6981
6982 let head = repo.find_commit(a).unwrap();
6985 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6986 let after = svc.handle("tree", Value::Null).await.unwrap();
6987 assert_eq!(
6988 repos_of(&after)[0]["worktrees"][0]
6989 .get("head_sha")
6990 .and_then(Value::as_str),
6991 Some(b.to_string().as_str())
6992 );
6993 assert_ne!(before, after, "a commit must be a visible snapshot delta");
6994 }
6995
6996 #[tokio::test]
6997 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6998 let dir = tempfile::tempdir().unwrap();
7001 init_repo(dir.path());
7002 let svc = WorktreesService::new();
7003 svc.handle(
7004 "register",
7005 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7006 )
7007 .await
7008 .unwrap();
7009 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7010 assert!(wt.get("head_sha").is_none(), "{wt:?}");
7011 }
7012
7013 #[tokio::test]
7016 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7017 let dir = tempfile::tempdir().unwrap();
7023 let repo = diverging_repo(dir.path());
7024 let svc = WorktreesService::new();
7025 svc.handle(
7026 "register",
7027 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7028 )
7029 .await
7030 .unwrap();
7031
7032 let before = svc.handle("tree", Value::Null).await.unwrap();
7033 let head = repo.head().unwrap().target().unwrap();
7034 assert_ne!(
7035 repos_of(&before)[0]["worktrees"][0]
7036 .get("upstream_sha")
7037 .and_then(Value::as_str),
7038 Some(head.to_string().as_str()),
7039 "the fixture must start un-pushed for this to prove anything"
7040 );
7041
7042 simulate_push(&repo, head);
7044 let after = svc.handle("tree", Value::Null).await.unwrap();
7045 let wt = &repos_of(&after)[0]["worktrees"][0];
7046 assert_eq!(
7047 wt.get("upstream_sha").and_then(Value::as_str),
7048 Some(head.to_string().as_str())
7049 );
7050 assert_eq!(
7053 wt.get("head_sha").and_then(Value::as_str),
7054 repos_of(&before)[0]["worktrees"][0]
7055 .get("head_sha")
7056 .and_then(Value::as_str)
7057 );
7058 assert_ne!(before, after, "a push must be a visible snapshot delta");
7059 }
7060
7061 #[tokio::test]
7062 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7063 let dir = tempfile::tempdir().unwrap();
7067 let repo = init_repo(dir.path());
7068 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7069 repo.set_head("refs/heads/main").unwrap();
7070 let svc = WorktreesService::new();
7071 svc.handle(
7072 "register",
7073 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7074 )
7075 .await
7076 .unwrap();
7077 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7078 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7079 assert!(wt.get("head_sha").is_some(), "{wt:?}");
7081 }
7082
7083 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7093 let guard = shim_lock();
7094 let path = dir.join("fake-gh");
7095 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7096 (path, guard)
7097 }
7098
7099 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7104 let guard = shim_lock();
7105 let path = dir.join("fake-gh");
7106 let counter = dir.join("gh-calls");
7107 write_exec_script(
7108 &path,
7109 &format!(
7110 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7111 counter = counter.display()
7112 ),
7113 );
7114 (path, guard, counter)
7115 }
7116
7117 fn gh_spawn_count(counter: &Path) -> usize {
7120 std::fs::read(counter).map_or(0, |b| b.len())
7121 }
7122
7123 fn counted_gh_records(log: &Path) -> usize {
7128 std::fs::read_to_string(log)
7129 .unwrap_or_default()
7130 .lines()
7131 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7132 .count()
7133 }
7134
7135 fn github_repo(dir: &Path) -> Repository {
7137 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7138 }
7139
7140 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7143 let repo = init_repo(dir);
7144 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7145 repo.set_head("refs/heads/main").unwrap();
7146 repo.remote("origin", url).unwrap();
7147 repo
7148 }
7149
7150 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7155 PrBadge {
7156 number,
7157 is_draft: false,
7158 checks: PrCheckState::Pending,
7159 url: "u".into(),
7160 head_oid: head_oid.to_string(),
7161 }
7162 }
7163
7164 fn pr(badge: PrBadge) -> PrResolution {
7166 PrResolution::Pr(badge)
7167 }
7168
7169 #[test]
7170 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7171 let snapshot = json!({"repos":[
7172 {
7173 "main_repo":"omni-dev",
7174 "github":{"owner":"rust-works","name":"omni-dev"},
7175 "root":"/r",
7176 "polling_enabled":true,
7179 "worktrees":[
7181 {"path":"/r","branch":"main","is_main":true,"open":true},
7182 {"path":"/w1","branch":"main","is_main":false,"open":true},
7183 {"path":"/w2","branch":"feature","is_main":false,"open":true},
7184 {"path":"/w3","is_main":false,"open":true}
7186 ]
7187 },
7188 {
7189 "main_repo":"local","root":"/l",
7191 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7192 }
7193 ]});
7194 let targets = pr_targets_from_snapshot(&snapshot);
7195 assert_eq!(
7196 targets,
7197 vec![
7198 PrTarget {
7199 owner: "rust-works".into(),
7200 name: "omni-dev".into(),
7201 branch: "feature".into()
7202 },
7203 PrTarget {
7204 owner: "rust-works".into(),
7205 name: "omni-dev".into(),
7206 branch: "main".into()
7207 },
7208 ]
7209 );
7210 }
7211
7212 #[test]
7213 fn pr_targets_from_snapshot_is_empty_without_repos() {
7214 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7215 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7216 }
7217
7218 #[test]
7219 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7220 for github in [
7223 json!({}),
7224 json!({"owner": "o"}),
7225 json!({"owner": 1, "name": 2}),
7226 ] {
7227 let snapshot = json!({"repos":[{
7228 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7229 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7230 }]});
7231 assert!(
7232 pr_targets_from_snapshot(&snapshot).is_empty(),
7233 "{snapshot:?}"
7234 );
7235 }
7236 }
7237
7238 #[test]
7239 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7240 for repo in [
7244 json!({
7245 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7246 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7247 }),
7248 json!({
7249 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7250 "polling_enabled":false,
7251 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7252 }),
7253 ] {
7254 let snapshot = json!({ "repos": [repo] });
7255 assert!(
7256 pr_targets_from_snapshot(&snapshot).is_empty(),
7257 "not-polled repo must yield no targets: {snapshot:?}"
7258 );
7259 }
7260 let enabled = json!({"repos":[{
7262 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7263 "polling_enabled":true,
7264 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7265 }]});
7266 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7267 }
7268
7269 #[test]
7270 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7271 let backoff = Duration::from_secs(600);
7272 assert!(pr_should_fetch(false, None, backoff));
7274 assert!(!pr_should_fetch(
7277 false,
7278 Some(Duration::from_secs(1)),
7279 backoff
7280 ));
7281 assert!(pr_should_fetch(false, Some(backoff), backoff));
7283 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7284 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7288 assert!(pr_should_fetch(
7289 true,
7290 Some(Duration::from_millis(1)),
7291 backoff
7292 ));
7293 }
7294
7295 #[test]
7296 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7297 let base = Duration::from_secs(10);
7298 let fresh = Some(Duration::ZERO);
7299 let stale = Some(PENDING_FAST_WINDOW);
7300 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7303 assert_eq!(
7304 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7305 base
7306 );
7307 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7310 assert_eq!(
7311 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7312 PENDING_MAX_INTERVAL
7313 );
7314 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7317 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7319 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7320 assert_eq!(
7322 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7323 MAX_PR_POLL_INTERVAL
7324 );
7325 assert_eq!(
7326 next_pr_poll_delay(Duration::MAX, base, false, None),
7327 MAX_PR_POLL_INTERVAL
7328 );
7329 }
7330
7331 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7333 PrWatch {
7334 target: PrTarget {
7335 owner: "rust-works".into(),
7336 name: "omni-dev".into(),
7337 branch: branch.into(),
7338 },
7339 upstream_sha: upstream.map(str::to_string),
7340 }
7341 }
7342
7343 #[test]
7344 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7345 let a = watch("a", Some("111"));
7346 let b = watch("b", Some("222"));
7347 let ab = [a.clone(), b.clone()];
7348 let just_a = std::slice::from_ref(&a);
7349 let just_b = std::slice::from_ref(&b);
7350 assert!(!pr_watch_grew(&ab, &ab));
7352 assert!(pr_watch_grew(just_a, &ab));
7354 assert!(!pr_watch_grew(&ab, just_a));
7356 assert!(pr_watch_grew(&[], just_a));
7358 let a_pushed = [watch("a", Some("999"))];
7360 assert!(pr_watch_grew(just_a, &a_pushed));
7361 assert!(pr_watch_grew(just_a, just_b));
7363 }
7364
7365 #[test]
7366 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7367 let base = Duration::from_secs(10);
7368 let over = RateLimitSnapshot {
7369 graphql: Some(rl_resource(90)),
7370 core: Some(rl_resource(3)),
7371 search: None,
7372 };
7373 let under = RateLimitSnapshot {
7374 graphql: Some(rl_resource(50)),
7375 core: Some(rl_resource(3)),
7376 search: None,
7377 };
7378 assert_eq!(budget_throttled_delay(base, None), base);
7380 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7382 assert_eq!(
7384 budget_throttled_delay(base, Some(&over)),
7385 BUDGET_THROTTLE_INTERVAL
7386 );
7387 let long = BUDGET_THROTTLE_INTERVAL * 2;
7389 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7390 }
7391
7392 #[test]
7393 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7394 let target = PrTarget {
7398 owner: "rust-works".into(),
7399 name: "omni-dev".into(),
7400 branch: "main".into(),
7401 };
7402 let badge = PrResolution::Pr(PrBadge {
7403 number: 1337,
7404 is_draft: true,
7405 checks: PrCheckState::Pending,
7406 url: "http://x/1337".into(),
7407 head_oid: "deadbeef".into(),
7408 });
7409 let watched = vec![watch("main", Some("abc"))];
7410 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7411 .unwrap()
7412 .with_timezone(&Utc);
7413 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7414
7415 let json = serde_json::to_vec(&prefs).unwrap();
7416 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7417 assert_eq!(back, prefs);
7418 assert_eq!(back.polled_at, Some(polled_at));
7419 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7420 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7422 }
7423
7424 #[test]
7425 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7426 let target = PrTarget {
7430 owner: "rust-works".into(),
7431 name: "omni-dev".into(),
7432 branch: "feature".into(),
7433 };
7434 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7435 let json = serde_json::to_vec(&prefs).unwrap();
7436 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7437 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7438 assert_eq!(
7439 back.entries[0].resolution.clone().into_resolution(),
7440 PrResolution::NoPr
7441 );
7442 }
7443
7444 #[test]
7445 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7446 let dir = tempfile::tempdir().unwrap();
7451 let path = dir.path().join("pr-cache.json");
7452 let target = PrTarget {
7453 owner: "rust-works".into(),
7454 name: "omni-dev".into(),
7455 branch: "main".into(),
7456 };
7457 let mut prefs = pr_cache_prefs_from(
7458 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7459 &[watch("main", None)],
7460 Utc::now(),
7461 );
7462 prefs.polled_at = None;
7463 write_pr_cache(&path, &prefs).unwrap();
7464
7465 let svc = WorktreesService::new();
7466 svc.load_pr_cache(path);
7467 assert!(
7468 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7469 "the badge itself must still restore"
7470 );
7471 assert!(
7472 svc.pr_warm_start
7473 .lock()
7474 .unwrap_or_else(PoisonError::into_inner)
7475 .is_none(),
7476 "no poll time means a cold start, not a trusted warm one"
7477 );
7478 }
7479
7480 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7484 tracing::subscriber::set_default(
7485 tracing_subscriber::fmt()
7486 .with_max_level(tracing::Level::WARN)
7487 .with_writer(std::io::sink)
7488 .finish(),
7489 )
7490 }
7491
7492 #[test]
7493 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7494 let _trace = warn_subscriber();
7498 let dir = tempfile::tempdir().unwrap();
7499 let corrupt = dir.path().join("pr-cache.json");
7500 std::fs::write(&corrupt, b"not json").unwrap();
7501 let svc = WorktreesService::new();
7502 svc.load_pr_cache(corrupt.clone());
7503 assert!(svc.pr_cache.entries().is_empty());
7504 assert_eq!(
7505 svc.pr_cache_path
7506 .lock()
7507 .unwrap_or_else(PoisonError::into_inner)
7508 .as_deref(),
7509 Some(corrupt.as_path()),
7510 "the path must be stored even when the load fails, so persistence recovers"
7511 );
7512
7513 let svc = WorktreesService::new();
7516 svc.load_pr_cache(dir.path().to_path_buf());
7517 assert!(svc.pr_cache.entries().is_empty());
7518 }
7519
7520 #[test]
7521 fn persist_pr_cache_swallows_a_write_failure() {
7522 let _trace = warn_subscriber();
7526 let dir = tempfile::tempdir().unwrap();
7527 let blocker = dir.path().join("blocker");
7528 std::fs::write(&blocker, b"").unwrap();
7529 let path = blocker.join("pr-cache.json");
7531 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7532 assert!(!path.exists());
7533 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7536 }
7537
7538 #[tokio::test]
7539 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7540 let dir = tempfile::tempdir().unwrap();
7541 let repo = github_repo(dir.path());
7542 let head = repo.head().unwrap().target().unwrap().to_string();
7543 let svc = WorktreesService::new();
7544 svc.handle(
7545 "register",
7546 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7547 )
7548 .await
7549 .unwrap();
7550 svc.registry.set_polling("rust-works", "omni-dev", true);
7553
7554 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7557 assert!(wt.get("pr").is_none(), "{wt:?}");
7558 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7559
7560 let mut badges = HashMap::new();
7562 badges.insert(
7563 PrTarget {
7564 owner: "rust-works".into(),
7565 name: "omni-dev".into(),
7566 branch: "main".into(),
7567 },
7568 pr(pending_badge(1337, &head)),
7569 );
7570 assert!(svc.pr_cache.replace(badges));
7571
7572 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7573 assert_eq!(wt["pr"]["number"], json!(1337));
7574 assert_eq!(wt["pr"]["checks"], json!("pending"));
7575 assert_eq!(wt["pr"]["isDraft"], json!(false));
7577 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7579 }
7580
7581 #[tokio::test]
7582 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7583 let dir = tempfile::tempdir().unwrap();
7588 let repo = github_repo(dir.path());
7589 let head = repo.head().unwrap().target().unwrap();
7590 repo.set_head_detached(head).unwrap();
7591
7592 let svc = WorktreesService::new();
7593 svc.handle(
7594 "register",
7595 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7596 )
7597 .await
7598 .unwrap();
7599 svc.registry.set_polling("rust-works", "omni-dev", true);
7602 let mut badges = HashMap::new();
7605 badges.insert(
7606 PrTarget {
7607 owner: "rust-works".into(),
7608 name: "omni-dev".into(),
7609 branch: "main".into(),
7610 },
7611 pr(pending_badge(1, &head.to_string())),
7612 );
7613 svc.pr_cache.replace(badges);
7614
7615 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7616 assert!(wt.get("branch").is_none(), "{wt:?}");
7617 assert_eq!(
7619 wt.get("head_sha").and_then(Value::as_str),
7620 Some(head.to_string().as_str())
7621 );
7622 assert!(wt.get("pr").is_none(), "{wt:?}");
7623 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7625 }
7626
7627 #[tokio::test]
7628 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7629 let dir = tempfile::tempdir().unwrap();
7630 github_repo(dir.path());
7631 let svc = WorktreesService::new();
7632 svc.handle(
7633 "register",
7634 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7635 )
7636 .await
7637 .unwrap();
7638 svc.registry.set_polling("rust-works", "omni-dev", true);
7641 let mut badges = HashMap::new();
7643 badges.insert(
7644 PrTarget {
7645 owner: "rust-works".into(),
7646 name: "omni-dev".into(),
7647 branch: "other".into(),
7648 },
7649 pr(pending_badge(1, "irrelevant")),
7650 );
7651 svc.pr_cache.replace(badges);
7652 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7653 assert!(wt.get("pr").is_none(), "{wt:?}");
7654 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7656 }
7657
7658 #[tokio::test]
7659 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7660 let dir = tempfile::tempdir().unwrap();
7664 github_repo(dir.path());
7665 let svc = WorktreesService::new();
7666 svc.handle(
7667 "register",
7668 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7669 )
7670 .await
7671 .unwrap();
7672 svc.registry.set_polling("rust-works", "omni-dev", true);
7675
7676 let mut resolutions = HashMap::new();
7677 resolutions.insert(
7678 PrTarget {
7679 owner: "rust-works".into(),
7680 name: "omni-dev".into(),
7681 branch: "main".into(),
7682 },
7683 PrResolution::NoPr,
7684 );
7685 assert!(svc.pr_cache.replace(resolutions));
7686
7687 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7688 assert_eq!(wt["pr_none"], json!(true));
7689 assert!(wt.get("pr").is_none(), "{wt:?}");
7691 }
7692
7693 #[tokio::test]
7694 async fn a_commit_does_not_drop_a_negative_resolution() {
7695 let dir = tempfile::tempdir().unwrap();
7700 let repo = github_repo(dir.path());
7701 let first = repo.head().unwrap().target().unwrap();
7702
7703 let svc = WorktreesService::new();
7704 svc.handle(
7705 "register",
7706 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7707 )
7708 .await
7709 .unwrap();
7710 svc.registry.set_polling("rust-works", "omni-dev", true);
7713
7714 let mut resolutions = HashMap::new();
7715 resolutions.insert(
7716 PrTarget {
7717 owner: "rust-works".into(),
7718 name: "omni-dev".into(),
7719 branch: "main".into(),
7720 },
7721 PrResolution::NoPr,
7722 );
7723 svc.pr_cache.replace(resolutions);
7724
7725 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726 assert_eq!(wt["pr_none"], json!(true));
7727
7728 let head = repo.find_commit(first).unwrap();
7730 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7731
7732 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7733 assert_eq!(
7734 wt["pr_none"],
7735 json!(true),
7736 "a local commit must not drop the negative"
7737 );
7738 }
7739
7740 #[tokio::test]
7741 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7742 let bin_dir = tempfile::tempdir().unwrap();
7746 let marker = bin_dir.path().join("spawned");
7747 let fake = bin_dir.path().join("fake-gh");
7748 std::fs::write(
7749 &fake,
7750 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7751 )
7752 .unwrap();
7753 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7754 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7755 std::fs::set_permissions(&fake, perms).unwrap();
7756
7757 let svc = WorktreesService::new();
7758 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7759 tokio::time::sleep(Duration::from_millis(200)).await;
7760 svc.shutdown().await;
7761 assert!(
7762 !marker.exists(),
7763 "the poller must not spawn gh with no windows registered"
7764 );
7765 }
7766
7767 #[tokio::test]
7768 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7769 let dir = tempfile::tempdir().unwrap();
7772 let repo = github_repo(dir.path());
7773 let head = repo.head().unwrap().target().unwrap().to_string();
7774 let bin_dir = tempfile::tempdir().unwrap();
7775 let fake = bin_dir.path().join("fake-gh");
7776 std::fs::write(
7778 &fake,
7779 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7780 )
7781 .unwrap();
7782 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7783 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7784 std::fs::set_permissions(&fake, perms).unwrap();
7785
7786 let svc = WorktreesService::new();
7787 svc.handle(
7788 "register",
7789 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7790 )
7791 .await
7792 .unwrap();
7793 svc.registry.set_polling("rust-works", "omni-dev", true);
7796 let mut seeded = HashMap::new();
7798 seeded.insert(
7799 PrTarget {
7800 owner: "rust-works".into(),
7801 name: "omni-dev".into(),
7802 branch: "main".into(),
7803 },
7804 pr(pending_badge(7, &head)),
7805 );
7806 svc.pr_cache.replace(seeded);
7807
7808 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7809 tokio::time::sleep(Duration::from_millis(200)).await;
7810
7811 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7814 assert_eq!(wt["pr"]["number"], json!(7));
7815 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7816 svc.shutdown().await;
7817 }
7818
7819 #[tokio::test]
7820 #[allow(clippy::await_holding_lock)]
7827 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7828 let dir = tempfile::tempdir().unwrap();
7834 github_repo(dir.path());
7835 let bin_dir = tempfile::tempdir().unwrap();
7836 let (fake, _shim) = fake_gh(
7837 bin_dir.path(),
7838 r#"{"data":{"r0":{"b0":{
7839 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7840 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7841 ]}}},
7842 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7843 }}}}"#,
7844 );
7845
7846 let svc = WorktreesService::new();
7847 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7849 tokio::time::sleep(Duration::from_millis(150)).await;
7850
7851 svc.handle(
7853 "register",
7854 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7855 )
7856 .await
7857 .unwrap();
7858 svc.registry.set_polling("rust-works", "omni-dev", true);
7861
7862 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7866 loop {
7867 if let Some(PrResolution::Pr(badge)) =
7868 svc.pr_cache.get("rust-works", "omni-dev", "main")
7869 {
7870 return badge;
7871 }
7872 tokio::time::sleep(Duration::from_millis(25)).await;
7873 }
7874 })
7875 .await
7876 .expect("a window opening must wake the poller out of its idle backoff");
7877 assert_eq!(badge.number, 99);
7878 svc.shutdown().await;
7879 }
7880
7881 #[tokio::test]
7882 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7883 let dir = tempfile::tempdir().unwrap();
7890 let repo = github_repo(dir.path());
7891 let first = repo.head().unwrap().target().unwrap();
7892
7893 let svc = WorktreesService::new();
7894 svc.handle(
7895 "register",
7896 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7897 )
7898 .await
7899 .unwrap();
7900 svc.registry.set_polling("rust-works", "omni-dev", true);
7903
7904 let mut badges = HashMap::new();
7906 badges.insert(
7907 PrTarget {
7908 owner: "rust-works".into(),
7909 name: "omni-dev".into(),
7910 branch: "main".into(),
7911 },
7912 pr(PrBadge {
7913 number: 1337,
7914 is_draft: false,
7915 checks: PrCheckState::Success,
7916 url: "u".into(),
7917 head_oid: first.to_string(),
7918 }),
7919 );
7920 svc.pr_cache.replace(badges);
7921
7922 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7923 assert_eq!(
7924 wt["pr"]["checks"],
7925 json!("success"),
7926 "green for its own commit"
7927 );
7928
7929 let head = repo.find_commit(first).unwrap();
7931 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7932
7933 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7934 assert_eq!(
7935 wt["pr"]["checks"],
7936 json!("pending"),
7937 "the previous commit's ✓ must not stand after a new commit"
7938 );
7939 assert_eq!(wt["pr"]["number"], json!(1337));
7942 }
7943
7944 #[test]
7945 fn is_stale_for_compares_the_commit_the_verdict_describes() {
7946 let badge = pending_badge(1, "aaa");
7947 assert!(!badge.is_stale_for(Some("aaa")));
7948 assert!(badge.is_stale_for(Some("bbb")));
7949 assert!(!badge.is_stale_for(None));
7951 }
7952
7953 #[test]
7954 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7955 let snap = |sha: &str| {
7960 json!({"repos":[{
7961 "main_repo":"omni-dev",
7962 "github":{"owner":"rust-works","name":"omni-dev"},
7963 "root":"/r",
7964 "polling_enabled":true,
7965 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7966 }]})
7967 };
7968 let before = pr_watch_from_snapshot(&snap("aaa"));
7969 let after = pr_watch_from_snapshot(&snap("bbb"));
7970 assert_eq!(before.len(), 1);
7971 assert_eq!(before[0].target, after[0].target);
7972 assert_eq!(before, after);
7974 assert!(!pr_watch_grew(&before, &after));
7975 }
7976
7977 #[test]
7978 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7979 let snap = |upstream: &str| {
7983 json!({"repos":[{
7984 "main_repo":"omni-dev",
7985 "github":{"owner":"rust-works","name":"omni-dev"},
7986 "root":"/r",
7987 "polling_enabled":true,
7988 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7989 "upstream_sha":upstream,"is_main":true,"open":true}]
7990 }]})
7991 };
7992 let before = pr_watch_from_snapshot(&snap("aaa"));
7993 let after = pr_watch_from_snapshot(&snap("bbb"));
7994 assert_eq!(before.len(), 1);
7996 assert_eq!(before[0].target, after[0].target);
7997 assert_ne!(before, after);
7998 assert!(pr_watch_grew(&before, &after));
7999 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8001 assert!(!pr_watch_grew(
8002 &before,
8003 &pr_watch_from_snapshot(&snap("aaa"))
8004 ));
8005 }
8006
8007 #[test]
8008 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8009 let snap = json!({"repos":[{
8012 "main_repo":"omni-dev",
8013 "github":{"owner":"rust-works","name":"omni-dev"},
8014 "root":"/r",
8015 "polling_enabled":true,
8016 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8017 }]});
8018 let watch = pr_watch_from_snapshot(&snap);
8019 assert_eq!(watch.len(), 1);
8020 assert_eq!(watch[0].upstream_sha, None);
8021 }
8022
8023 #[test]
8024 fn start_pr_poller_is_a_noop_outside_a_runtime() {
8025 let svc = WorktreesService::new();
8026 svc.start_pr_poller();
8027 assert!(svc
8028 .poller
8029 .lock()
8030 .unwrap_or_else(PoisonError::into_inner)
8031 .is_none());
8032 }
8033
8034 #[tokio::test]
8035 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8036 let svc = WorktreesService::new();
8037 svc.start_pr_poller_with(
8038 Duration::from_millis(50),
8039 Duration::from_millis(10),
8040 PathBuf::from("/bin/true"),
8041 );
8042 let token = svc
8043 .poller
8044 .lock()
8045 .unwrap_or_else(PoisonError::into_inner)
8046 .as_ref()
8047 .map(|t| t.token.clone())
8048 .expect("poller started");
8049
8050 token.cancel();
8053 svc.start_pr_poller_with(
8054 Duration::from_millis(50),
8055 Duration::from_millis(10),
8056 PathBuf::from("/bin/true"),
8057 );
8058 assert!(svc
8059 .poller
8060 .lock()
8061 .unwrap_or_else(PoisonError::into_inner)
8062 .as_ref()
8063 .is_some_and(|t| t.token.is_cancelled()));
8064
8065 svc.shutdown().await;
8066 assert!(svc
8067 .poller
8068 .lock()
8069 .unwrap_or_else(PoisonError::into_inner)
8070 .is_none());
8071 }
8072
8073 fn rl_resource(used: u64) -> RateLimitResource {
8077 RateLimitResource {
8078 used,
8079 limit: 100,
8080 remaining: 100 - used,
8081 percent: used as f64,
8082 reset: 0,
8083 }
8084 }
8085
8086 #[test]
8087 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8088 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8089 graphql: Some(rl_resource(graphql)),
8090 core: Some(rl_resource(core)),
8091 search: None,
8092 };
8093 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8095 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8097 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8099 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8101 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8103 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8105 }
8106
8107 #[test]
8108 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8109 let svc = WorktreesService::new();
8110 svc.start_rate_limit_poller();
8111 assert!(svc
8112 .rate_limit_poller
8113 .lock()
8114 .unwrap_or_else(PoisonError::into_inner)
8115 .is_none());
8116 }
8117
8118 #[tokio::test]
8119 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8120 let svc = WorktreesService::new();
8121 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8122 let token = svc
8123 .rate_limit_poller
8124 .lock()
8125 .unwrap_or_else(PoisonError::into_inner)
8126 .as_ref()
8127 .map(|t| t.token.clone())
8128 .expect("poller started");
8129
8130 token.cancel();
8133 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8134 assert!(svc
8135 .rate_limit_poller
8136 .lock()
8137 .unwrap_or_else(PoisonError::into_inner)
8138 .as_ref()
8139 .is_some_and(|t| t.token.is_cancelled()));
8140
8141 svc.shutdown().await;
8142 assert!(svc
8143 .rate_limit_poller
8144 .lock()
8145 .unwrap_or_else(PoisonError::into_inner)
8146 .is_none());
8147 }
8148
8149 #[tokio::test]
8150 #[allow(clippy::await_holding_lock)]
8152 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8153 let bin_dir = tempfile::tempdir().unwrap();
8154 let (fake, _shim) = fake_gh(
8155 bin_dir.path(),
8156 r#"{"resources":{
8157 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8158 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8159 }}"#,
8160 );
8161 let svc = WorktreesService::new();
8162 svc.registry.set_polling("rust-works", "omni-dev", true);
8165 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8166
8167 let snap = tokio::time::timeout(Duration::from_secs(30), async {
8170 loop {
8171 if let Some(snap) = svc.rate_limit_cache.get() {
8172 return snap;
8173 }
8174 tokio::time::sleep(Duration::from_millis(25)).await;
8175 }
8176 })
8177 .await
8178 .expect("poller should populate the cache through the fake gh");
8179 assert_eq!(snap.graphql.unwrap().used, 4100);
8180 assert_eq!(snap.core.unwrap().used, 27);
8181
8182 assert!(svc.rate_limit_cache().get().is_some());
8184
8185 svc.shutdown().await;
8186 assert!(svc
8187 .rate_limit_poller
8188 .lock()
8189 .unwrap_or_else(PoisonError::into_inner)
8190 .is_none());
8191 }
8192
8193 #[tokio::test]
8194 #[allow(clippy::await_holding_lock)]
8196 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8197 let bin_dir = tempfile::tempdir().unwrap();
8200 let (fake, _shim, counter) = counting_fake_gh(
8201 bin_dir.path(),
8202 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8203 );
8204 let svc = WorktreesService::new();
8205 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8206
8207 tokio::time::sleep(Duration::from_millis(300)).await;
8209 assert_eq!(
8210 gh_spawn_count(&counter),
8211 0,
8212 "idle daemon must not poll (#1389, fix 8b)"
8213 );
8214 assert!(svc.rate_limit_cache.get().is_none());
8215
8216 svc.registry.set_polling("rust-works", "omni-dev", true);
8218 tokio::time::timeout(Duration::from_secs(30), async {
8219 loop {
8220 if svc.rate_limit_cache.get().is_some() {
8221 return;
8222 }
8223 tokio::time::sleep(Duration::from_millis(25)).await;
8224 }
8225 })
8226 .await
8227 .expect("an active lease should resume polling");
8228 assert!(gh_spawn_count(&counter) >= 1);
8229 svc.shutdown().await;
8230 }
8231
8232 #[tokio::test]
8233 async fn rate_limit_poller_survives_a_failing_gh() {
8234 let svc = WorktreesService::new();
8238 svc.registry.set_polling("rust-works", "omni-dev", true);
8239 svc.start_rate_limit_poller_with(
8240 Duration::from_millis(20),
8241 PathBuf::from("/no/such/gh/xyzzy"),
8242 );
8243 tokio::time::sleep(Duration::from_millis(150)).await;
8245 assert!(svc.rate_limit_cache.get().is_none());
8246 assert!(
8247 svc.rate_limit_poller
8248 .lock()
8249 .unwrap_or_else(PoisonError::into_inner)
8250 .is_some(),
8251 "the loop must survive a failing gh, not panic out"
8252 );
8253 svc.shutdown().await;
8254 }
8255
8256 #[test]
8257 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8258 let svc = WorktreesService::new();
8259 let items = svc.menu().items;
8261 assert!(
8262 !items
8263 .iter()
8264 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8265 "no github line before the first poll"
8266 );
8267
8268 svc.rate_limit_cache.replace(RateLimitSnapshot {
8270 graphql: Some(rl_resource(82)),
8271 core: Some(rl_resource(3)),
8272 search: None,
8273 });
8274 let items = svc.menu().items;
8275 assert!(
8276 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8277 "expected the github line first, got {items:?}"
8278 );
8279 assert!(
8280 matches!(items.get(1), Some(MenuItem::Separator)),
8281 "expected a separator after the github line"
8282 );
8283 }
8284
8285 #[tokio::test]
8286 #[allow(clippy::await_holding_lock)]
8288 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8289 let dir = tempfile::tempdir().unwrap();
8290 github_repo(dir.path());
8291 let bin_dir = tempfile::tempdir().unwrap();
8292 let (fake, _shim) = fake_gh(
8295 bin_dir.path(),
8296 r#"{"data":{"r0":{"b0":{
8297 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8298 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8299 ]}}},
8300 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8301 }}}}"#,
8302 );
8303 let svc = WorktreesService::new();
8304 svc.handle(
8305 "register",
8306 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8307 )
8308 .await
8309 .unwrap();
8310 svc.registry.set_polling("rust-works", "omni-dev", true);
8313 svc.start_pr_poller_with(
8314 Duration::from_millis(50),
8315 Duration::from_millis(10),
8316 fake.clone(),
8317 );
8318
8319 let badge = tokio::time::timeout(Duration::from_secs(30), async {
8323 loop {
8324 if let Some(PrResolution::Pr(badge)) =
8325 svc.pr_cache.get("rust-works", "omni-dev", "main")
8326 {
8327 return badge;
8328 }
8329 tokio::time::sleep(Duration::from_millis(25)).await;
8330 }
8331 })
8332 .await
8333 .expect("poller should resolve a badge through the fake gh");
8334 assert_eq!(badge.number, 1337);
8335 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8336
8337 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8339 assert_eq!(wt["pr"]["number"], json!(1337));
8340
8341 svc.shutdown().await;
8343 let generation = svc.registry.change_generation();
8344 tokio::time::sleep(Duration::from_millis(120)).await;
8345 assert_eq!(
8346 svc.registry.change_generation(),
8347 generation,
8348 "no bumps after shutdown"
8349 );
8350 }
8351
8352 #[tokio::test]
8353 #[allow(clippy::await_holding_lock)]
8355 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8356 let dir = tempfile::tempdir().unwrap();
8360 github_repo(dir.path());
8361 let bin_dir = tempfile::tempdir().unwrap();
8362 let (fake, _shim) = fake_gh(
8363 bin_dir.path(),
8364 r#"{"data":{
8365 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8366 "resetAt":"2026-07-21T16:00:00Z"},
8367 "r0":{"b0":{
8368 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8369 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8370 ]}}},
8371 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8372 }}
8373 }}"#,
8374 );
8375 let svc = WorktreesService::new();
8376 svc.handle(
8377 "register",
8378 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8379 )
8380 .await
8381 .unwrap();
8382 svc.registry.set_polling("rust-works", "omni-dev", true);
8383 svc.start_pr_poller_with(
8386 Duration::from_millis(50),
8387 Duration::from_millis(10),
8388 fake.clone(),
8389 );
8390
8391 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8392 loop {
8393 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8394 return g;
8395 }
8396 tokio::time::sleep(Duration::from_millis(25)).await;
8397 }
8398 })
8399 .await
8400 .expect("the PR poll should fold its budget into the cache");
8401 assert_eq!(graphql.used, 123);
8402 assert_eq!(graphql.limit, 5000);
8403 assert_eq!(graphql.remaining, 4877);
8404 svc.shutdown().await;
8405 }
8406
8407 #[tokio::test]
8408 #[allow(clippy::await_holding_lock)]
8410 async fn pr_poll_counts_every_gh_call_exactly_once() {
8411 let dir = tempfile::tempdir().unwrap();
8418 github_repo(dir.path());
8419 let bin_dir = tempfile::tempdir().unwrap();
8420 let (fake, _shim, counter) = counting_fake_gh(
8421 bin_dir.path(),
8422 r#"{"data":{"r0":{"b0":{
8423 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8424 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8425 ]}}},
8426 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8427 }}}}"#,
8428 );
8429 let log = bin_dir.path().join("log.jsonl");
8430 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8431
8432 let svc = WorktreesService::new();
8433 svc.handle(
8434 "register",
8435 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8436 )
8437 .await
8438 .unwrap();
8439 svc.registry.set_polling("rust-works", "omni-dev", true);
8440 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8441
8442 tokio::time::timeout(Duration::from_secs(30), async {
8444 loop {
8445 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8446 return;
8447 }
8448 tokio::time::sleep(Duration::from_millis(25)).await;
8449 }
8450 })
8451 .await
8452 .expect("poller should fetch through the fake gh");
8453
8454 svc.shutdown().await;
8456 let spawns = gh_spawn_count(&counter);
8457 let counted = counted_gh_records(&log);
8458 std::env::remove_var("OMNI_DEV_LOG_FILE");
8459 assert!(
8460 spawns >= 1,
8461 "the poll should have spent at least one gh call"
8462 );
8463 assert_eq!(
8464 counted, spawns,
8465 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8466 );
8467 }
8468
8469 #[tokio::test]
8470 #[allow(clippy::await_holding_lock)]
8472 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8473 let dir_a = tempfile::tempdir().unwrap();
8478 let dir_b = tempfile::tempdir().unwrap();
8479 github_repo(dir_a.path()); github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); let bin_dir = tempfile::tempdir().unwrap();
8482 let (fake, _shim, counter) = counting_fake_gh(
8484 bin_dir.path(),
8485 r#"{"data":{
8486 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8487 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8488 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8489 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8490 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8491 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8492 }}"#,
8493 );
8494 let svc = WorktreesService::new();
8495 svc.registry.set_polling("rust-works", "omni-dev", true);
8498 svc.registry.set_polling("rust-works", "other-repo", true);
8499 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8503 svc.handle(
8505 "register",
8506 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8507 )
8508 .await
8509 .unwrap();
8510 tokio::time::sleep(Duration::from_millis(50)).await;
8514 svc.handle(
8515 "register",
8516 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8517 )
8518 .await
8519 .unwrap();
8520
8521 tokio::time::timeout(Duration::from_secs(30), async {
8524 loop {
8525 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8526 let b = svc
8527 .pr_cache
8528 .get("rust-works", "other-repo", "main")
8529 .is_some();
8530 if a && b {
8531 return;
8532 }
8533 tokio::time::sleep(Duration::from_millis(25)).await;
8534 }
8535 })
8536 .await
8537 .expect("the debounced fetch should resolve both repos");
8538
8539 svc.shutdown().await;
8540 assert_eq!(
8541 gh_spawn_count(&counter),
8542 1,
8543 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8544 );
8545 }
8546
8547 #[tokio::test]
8548 #[allow(clippy::await_holding_lock)]
8550 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8551 let dir = tempfile::tempdir().unwrap();
8556 github_repo(dir.path());
8557 let bin_dir = tempfile::tempdir().unwrap();
8558 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8559 let svc = WorktreesService::new();
8560 svc.registry.set_polling("rust-works", "omni-dev", true);
8561 svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8565 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8566 svc.handle("register", register.clone()).await.unwrap();
8567 let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8577 loop {
8578 tokio::time::sleep(Duration::from_millis(25)).await;
8579 svc.handle("register", register.clone()).await.unwrap();
8580 if gh_spawn_count(&counter) >= 1 {
8581 return;
8582 }
8583 }
8584 })
8585 .await;
8586 svc.shutdown().await;
8587 forced_mid_drip.expect(
8588 "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8589 );
8590 }
8591
8592 #[tokio::test]
8593 #[allow(clippy::await_holding_lock)]
8595 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8596 let dir = tempfile::tempdir().unwrap();
8600 let repo = github_repo(dir.path());
8601 let head = repo.head().unwrap().target().unwrap().to_string();
8602 let bin_dir = tempfile::tempdir().unwrap();
8603 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8606
8607 let cache_path = bin_dir.path().join("pr-cache.json");
8611 let target = PrTarget {
8612 owner: "rust-works".into(),
8613 name: "omni-dev".into(),
8614 branch: "main".into(),
8615 };
8616 let prefs = pr_cache_prefs_from(
8617 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8618 &[watch("main", None)],
8619 Utc::now(),
8620 );
8621 write_pr_cache(&cache_path, &prefs).unwrap();
8622
8623 let svc = WorktreesService::new();
8624 svc.load_pr_cache(cache_path);
8625 svc.registry.set_polling("rust-works", "omni-dev", true);
8626 svc.handle(
8627 "register",
8628 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8629 )
8630 .await
8631 .unwrap();
8632 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8635
8636 let number = tokio::time::timeout(Duration::from_secs(30), async {
8638 loop {
8639 let tree = svc.handle("tree", Value::Null).await.unwrap();
8640 if let Some(n) = repos_of(&tree)
8641 .first()
8642 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8643 {
8644 return n;
8645 }
8646 tokio::time::sleep(Duration::from_millis(25)).await;
8647 }
8648 })
8649 .await
8650 .expect("the restored badge should render from the warm cache");
8651 assert_eq!(number, 1337);
8652
8653 tokio::time::sleep(Duration::from_millis(300)).await;
8655 svc.shutdown().await;
8656 assert_eq!(
8657 gh_spawn_count(&counter),
8658 0,
8659 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8660 );
8661 }
8662
8663 #[tokio::test]
8664 #[allow(clippy::await_holding_lock)]
8666 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8667 let dir = tempfile::tempdir().unwrap();
8672 github_repo(dir.path());
8673 let bin_dir = tempfile::tempdir().unwrap();
8674 let (fake, _shim) = fake_gh(
8675 bin_dir.path(),
8676 r#"{"data":{"r0":{"b0":{
8677 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8678 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8679 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8680 }}}}"#,
8681 );
8682 let svc = WorktreesService::new();
8683 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8686 svc.load_pr_cache(cache_path.clone());
8687 svc.registry.set_polling("rust-works", "omni-dev", true);
8688 svc.handle(
8689 "register",
8690 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8691 )
8692 .await
8693 .unwrap();
8694 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8695
8696 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8699 loop {
8700 if let Ok(bytes) = std::fs::read(&cache_path) {
8701 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8702 if !prefs.entries.is_empty() {
8703 return prefs;
8704 }
8705 }
8706 }
8707 tokio::time::sleep(Duration::from_millis(25)).await;
8708 }
8709 })
8710 .await
8711 .expect("a successful resolve should persist the cache file");
8712 svc.shutdown().await;
8713
8714 assert_eq!(prefs.entries[0].target.branch, "main");
8715 assert!(
8716 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8717 "{:?}",
8718 prefs.entries[0].resolution
8719 );
8720 assert_eq!(
8721 prefs.watched,
8722 vec![PersistedWatch {
8723 target: prefs.entries[0].target.clone(),
8724 upstream_sha: None
8725 }]
8726 );
8727 assert!(
8728 prefs.polled_at.is_some(),
8729 "the poll time is what ages the next warm start"
8730 );
8731 }
8732
8733 #[tokio::test]
8734 #[allow(clippy::await_holding_lock)]
8736 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8737 let bin_dir = tempfile::tempdir().unwrap();
8741 let (fake, _shim, counter) = counting_fake_gh(
8742 bin_dir.path(),
8743 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8744 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8745 );
8746 let svc = WorktreesService::new();
8747
8748 let prs = svc
8749 .open_prs_with("rust-works", "omni-dev", fake.clone())
8750 .await
8751 .expect("gh pr list should resolve");
8752 assert_eq!(prs.len(), 1);
8753 assert_eq!(prs[0]["number"], json!(42));
8754 assert_eq!(prs[0]["url"], json!("http://x/42"));
8755 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8756
8757 let again = svc
8759 .open_prs_with("rust-works", "omni-dev", fake.clone())
8760 .await
8761 .expect("cache hit should resolve");
8762 assert_eq!(again, prs);
8763 assert_eq!(
8764 gh_spawn_count(&counter),
8765 1,
8766 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8767 );
8768
8769 let reply = svc
8771 .handle(
8772 "open-prs",
8773 json!({ "owner": "rust-works", "name": "omni-dev" }),
8774 )
8775 .await
8776 .expect("open-prs op should route");
8777 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8778 assert!(svc
8779 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
8780 .await
8781 .is_err());
8782 }
8783
8784 #[test]
8785 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8786 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8791 assert!(
8792 err.to_string().contains("is the GitHub CLI installed"),
8793 "{err:#}"
8794 );
8795
8796 let bin_dir = tempfile::tempdir().unwrap();
8797 let _guard = shim_lock();
8798 let failing = bin_dir.path().join("fake-gh-fails");
8799 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8800 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8801 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8802 assert!(err.to_string().contains("boom"), "{err:#}");
8803
8804 let object = bin_dir.path().join("fake-gh-object");
8805 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8806 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8807 assert!(
8808 err.to_string().contains("did not return a JSON array"),
8809 "{err:#}"
8810 );
8811 }
8812
8813 #[tokio::test]
8814 #[allow(clippy::await_holding_lock)]
8816 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8817 let dir = tempfile::tempdir().unwrap();
8823 github_repo(dir.path());
8824 let bin_dir = tempfile::tempdir().unwrap();
8825 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8826
8827 let svc = WorktreesService::new();
8828 *svc.pr_warm_start
8832 .lock()
8833 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8834 watched: vec![],
8835 polled_at: Utc::now(),
8836 });
8837 svc.rate_limit_cache.replace(RateLimitSnapshot {
8839 graphql: Some(rl_resource(90)),
8840 core: Some(rl_resource(3)),
8841 search: None,
8842 });
8843 svc.registry.set_polling("rust-works", "omni-dev", true);
8844 svc.handle(
8845 "register",
8846 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8847 )
8848 .await
8849 .unwrap();
8850 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8853
8854 tokio::time::sleep(Duration::from_millis(300)).await;
8856 svc.shutdown().await;
8857 assert_eq!(
8858 gh_spawn_count(&counter),
8859 0,
8860 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8861 );
8862 }
8863
8864 #[tokio::test]
8865 #[allow(clippy::await_holding_lock)]
8867 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8868 let dir = tempfile::tempdir().unwrap();
8871 github_repo(dir.path());
8872 let bin_dir = tempfile::tempdir().unwrap();
8873 let (fake, _shim) = fake_gh(
8874 bin_dir.path(),
8875 r#"{"data":{"r0":{"b0":{
8876 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8877 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8878 ]}}},
8879 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8880 }}}}"#,
8881 );
8882 let svc = WorktreesService::new();
8883 svc.handle(
8884 "register",
8885 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8886 )
8887 .await
8888 .unwrap();
8889 svc.registry.set_polling("rust-works", "omni-dev", true);
8892 svc.start_pr_poller_with(
8893 Duration::from_millis(50),
8894 Duration::from_millis(10),
8895 fake.clone(),
8896 );
8897
8898 tokio::time::timeout(Duration::from_secs(30), async {
8899 loop {
8900 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8901 return;
8902 }
8903 tokio::time::sleep(Duration::from_millis(25)).await;
8904 }
8905 })
8906 .await
8907 .expect("poller should resolve a badge through the fake gh");
8908 let settled = svc.registry.change_generation();
8911 tokio::time::sleep(Duration::from_millis(150)).await;
8912 assert_eq!(
8913 svc.registry.change_generation(),
8914 settled,
8915 "an unchanged poll must not bump the change-notify"
8916 );
8917 svc.shutdown().await;
8918 }
8919
8920 #[tokio::test]
8921 #[allow(clippy::await_holding_lock)]
8923 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8924 let dir = tempfile::tempdir().unwrap();
8929 github_repo(dir.path());
8930 let bin_dir = tempfile::tempdir().unwrap();
8931 let (fake, _shim) = fake_gh(
8932 bin_dir.path(),
8933 r#"{"data":{"r0":{"b0":{
8934 "target":{"oid":"abc","statusCheckRollup":null},
8935 "associatedPullRequests":{"nodes":[]}
8936 }}}}"#,
8937 );
8938 let svc = WorktreesService::new();
8939 svc.handle(
8940 "register",
8941 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8942 )
8943 .await
8944 .unwrap();
8945 svc.registry.set_polling("rust-works", "omni-dev", true);
8948 svc.start_pr_poller_with(
8949 Duration::from_millis(50),
8950 Duration::from_millis(10),
8951 fake.clone(),
8952 );
8953
8954 tokio::time::timeout(Duration::from_secs(30), async {
8955 loop {
8956 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8957 return;
8958 }
8959 tokio::time::sleep(Duration::from_millis(25)).await;
8960 }
8961 })
8962 .await
8963 .expect("poller should resolve the negative through the fake gh");
8964
8965 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8967 assert_eq!(wt["pr_none"], json!(true));
8968 assert!(wt.get("pr").is_none(), "{wt:?}");
8969
8970 let settled = svc.registry.change_generation();
8972 tokio::time::sleep(Duration::from_millis(150)).await;
8973 assert_eq!(
8974 svc.registry.change_generation(),
8975 settled,
8976 "an unchanged negative must not bump the change-notify"
8977 );
8978 svc.shutdown().await;
8979 }
8980
8981 #[test]
8982 fn sync_indicator_formats_only_with_upstream() {
8983 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8984 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8985 assert_eq!(sync_indicator(None, None), None);
8986 assert_eq!(sync_indicator(Some(1), None), None);
8988 }
8989
8990 #[tokio::test]
8991 async fn list_enriches_entries_with_git_status() {
8992 let dir = tempfile::tempdir().unwrap();
8993 let repo = init_repo(dir.path());
8994 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8995 repo.set_head("refs/heads/main").unwrap();
8996
8997 let svc = WorktreesService::new();
8998 svc.handle(
8999 "register",
9000 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9001 )
9002 .await
9003 .unwrap();
9004 let payload = svc.handle("list", Value::Null).await.unwrap();
9005 let windows = windows_of(&payload);
9006 assert_eq!(windows.len(), 1);
9007 assert_eq!(
9008 windows[0].get("branch").and_then(Value::as_str),
9009 Some("main")
9010 );
9011 assert!(windows[0].get("ahead").is_none());
9013 assert!(windows[0].get("behind").is_none());
9014 assert_eq!(
9016 windows[0].get("main_repo").and_then(Value::as_str),
9017 dir.path().file_name().and_then(|n| n.to_str())
9018 );
9019
9020 let plain = tempfile::tempdir().unwrap();
9022 svc.handle(
9023 "register",
9024 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9025 )
9026 .await
9027 .unwrap();
9028 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9029 let w2 = windows
9030 .iter()
9031 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9032 .unwrap();
9033 assert!(w2.get("branch").is_none());
9034 assert!(w2.get("main_repo").is_none());
9035 }
9036
9037 #[test]
9038 fn window_label_prefers_git_branch_over_title() {
9039 let dir = tempfile::tempdir().unwrap();
9040 let repo = init_repo(dir.path());
9041 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9042 repo.set_head("refs/heads/main").unwrap();
9043 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9044 let entry = WindowEntry {
9045 key: "k".to_string(),
9046 folders: vec![dir.path().to_path_buf()],
9047 repo: Some("companion-repo".to_string()),
9050 title: Some("ignored title".to_string()),
9051 pid: None,
9052 last_seen: Utc::now(),
9053 };
9054 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9056 }
9057
9058 #[tokio::test]
9059 async fn list_includes_ahead_behind_for_tracking_branch() {
9060 let dir = tempfile::tempdir().unwrap();
9061 let _repo = diverging_repo(dir.path());
9062
9063 let svc = WorktreesService::new();
9064 svc.handle(
9065 "register",
9066 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9067 )
9068 .await
9069 .unwrap();
9070 let payload = svc.handle("list", Value::Null).await.unwrap();
9071 let windows = windows_of(&payload);
9072 assert_eq!(
9074 windows[0].get("branch").and_then(Value::as_str),
9075 Some("main")
9076 );
9077 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9078 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9079 }
9080
9081 #[test]
9082 fn window_label_includes_sync_for_tracking_branch() {
9083 let dir = tempfile::tempdir().unwrap();
9084 let _repo = diverging_repo(dir.path());
9085 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9086 let entry = WindowEntry {
9087 key: "k".to_string(),
9088 folders: vec![dir.path().to_path_buf()],
9089 repo: Some("companion-repo".to_string()),
9090 title: None,
9091 pid: None,
9092 last_seen: Utc::now(),
9093 };
9094 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9096 }
9097
9098 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9102 let commit = repo.find_commit(base).unwrap();
9103 repo.branch(branch, &commit, false).unwrap();
9104 let reference = repo
9105 .find_reference(&format!("refs/heads/{branch}"))
9106 .unwrap();
9107 let mut opts = git2::WorktreeAddOptions::new();
9108 opts.reference(Some(&reference));
9109 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9110 }
9111
9112 #[test]
9113 fn git_status_marks_linked_worktree_and_names_parent_repo() {
9114 let main_dir = tempfile::tempdir().unwrap();
9115 let repo = init_repo(main_dir.path());
9116 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9117 repo.set_head("refs/heads/main").unwrap();
9118
9119 let wt_parent = tempfile::tempdir().unwrap();
9122 let wt_path = wt_parent.path().join("feature-wt");
9123 add_worktree(&repo, a, &wt_path, "feature");
9124
9125 let status = git_status(&wt_path);
9126 assert!(status.is_worktree);
9127 assert_eq!(status.branch.as_deref(), Some("feature"));
9128 assert_eq!(
9130 status.main_repo.as_deref(),
9131 main_dir.path().file_name().and_then(|n| n.to_str())
9132 );
9133
9134 let main_status = git_status(main_dir.path());
9136 assert!(!main_status.is_worktree);
9137 assert_eq!(main_status.main_repo, status.main_repo);
9138 }
9139
9140 #[test]
9141 fn window_label_marks_worktree_with_fork_glyph() {
9142 let main_dir = tempfile::tempdir().unwrap();
9143 let repo = init_repo(main_dir.path());
9144 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9145 repo.set_head("refs/heads/main").unwrap();
9146 let wt_parent = tempfile::tempdir().unwrap();
9147 let wt_path = wt_parent.path().join("feature-wt");
9148 add_worktree(&repo, a, &wt_path, "feature");
9149
9150 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9151 let entry = WindowEntry {
9152 key: "k".to_string(),
9153 folders: vec![wt_path],
9154 repo: Some("feature-wt".to_string()),
9155 title: None,
9156 pid: None,
9157 last_seen: Utc::now(),
9158 };
9159 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9162 }
9163
9164 #[test]
9165 fn main_repo_name_derives_from_common_dir() {
9166 assert_eq!(
9168 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9169 Some("omni-dev")
9170 );
9171 assert_eq!(
9173 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9174 Some("omni-dev")
9175 );
9176 assert_eq!(
9178 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9179 Some("omni-dev")
9180 );
9181 assert_eq!(main_repo_name(Path::new("/.git")), None);
9183 }
9184
9185 fn repos_of(payload: &Value) -> Vec<Value> {
9190 payload
9191 .get("repos")
9192 .and_then(Value::as_array)
9193 .expect("repos array")
9194 .clone()
9195 }
9196
9197 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9198 Some(GithubIdentity {
9199 owner: owner.to_string(),
9200 name: name.to_string(),
9201 })
9202 }
9203
9204 #[test]
9205 fn github_identity_parses_supported_forms() {
9206 assert_eq!(
9208 github_identity("https://github.com/rust-works/omni-dev.git"),
9209 github("rust-works", "omni-dev")
9210 );
9211 assert_eq!(
9212 github_identity("https://github.com/rust-works/omni-dev"),
9213 github("rust-works", "omni-dev")
9214 );
9215 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9216 assert_eq!(
9218 github_identity("git@github.com:rust-works/omni-dev.git"),
9219 github("rust-works", "omni-dev")
9220 );
9221 assert_eq!(
9222 github_identity("ssh://git@github.com/o/r.git"),
9223 github("o", "r")
9224 );
9225 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9226 assert_eq!(
9228 github_identity(" https://github.com/o/r/ "),
9229 github("o", "r")
9230 );
9231 }
9232
9233 #[test]
9234 fn github_identity_rejects_non_github_and_malformed() {
9235 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9237 assert_eq!(github_identity("git@example.com:o/r.git"), None);
9238 assert_eq!(github_identity("https://github.com/onlyowner"), None);
9240 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9241 assert_eq!(github_identity("https://github.com/"), None);
9242 assert_eq!(github_identity("not a url"), None);
9244 }
9245
9246 #[test]
9247 fn remote_github_identity_reads_origin_then_falls_back() {
9248 let dir = tempfile::tempdir().unwrap();
9249 let repo = init_repo(dir.path());
9250 assert_eq!(remote_github_identity(&repo), None);
9252 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9254 assert_eq!(remote_github_identity(&repo), None);
9255 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9257 .unwrap();
9258 assert_eq!(
9259 remote_github_identity(&repo),
9260 github("rust-works", "omni-dev")
9261 );
9262
9263 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9266 .unwrap();
9267 repo.remote("upstream", "https://github.com/other/proj.git")
9268 .unwrap();
9269 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9270 }
9271
9272 #[tokio::test]
9273 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9274 let svc = WorktreesService::new();
9275 assert_eq!(
9277 svc.handle("tree", Value::Null).await.unwrap(),
9278 json!({ "repos": [], "show_closed": true })
9279 );
9280 let plain = tempfile::tempdir().unwrap();
9282 svc.handle(
9283 "register",
9284 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9285 )
9286 .await
9287 .unwrap();
9288 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9289 }
9290
9291 #[tokio::test]
9292 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9293 let main_dir = tempfile::tempdir().unwrap();
9294 let repo = init_repo(main_dir.path());
9295 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9296 repo.set_head("refs/heads/main").unwrap();
9297 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9299 .unwrap();
9300
9301 let wt_parent = tempfile::tempdir().unwrap();
9304 let wt_path = wt_parent.path().join("feature-wt");
9305 add_worktree(&repo, a, &wt_path, "feature");
9306
9307 let svc = WorktreesService::new();
9308 svc.handle(
9311 "register",
9312 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9313 )
9314 .await
9315 .unwrap();
9316 svc.handle(
9317 "register",
9318 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9319 )
9320 .await
9321 .unwrap();
9322
9323 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9324 assert_eq!(
9325 repos.len(),
9326 1,
9327 "two worktrees of one repo dedupe: {repos:?}"
9328 );
9329 let repo0 = &repos[0];
9330 assert_eq!(
9332 repo0.get("main_repo").and_then(Value::as_str),
9333 main_dir.path().file_name().and_then(|n| n.to_str())
9334 );
9335 assert_eq!(
9336 repo0.pointer("/github/owner").and_then(Value::as_str),
9337 Some("rust-works")
9338 );
9339 assert_eq!(
9340 repo0.pointer("/github/name").and_then(Value::as_str),
9341 Some("omni-dev")
9342 );
9343 assert!(repo0.get("root").and_then(Value::as_str).is_some());
9344
9345 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9346 assert_eq!(worktrees.len(), 2);
9347 let main_wt = &worktrees[0];
9349 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9350 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9351 assert_eq!(
9352 main_wt.get("window_key").and_then(Value::as_str),
9353 Some("wm")
9354 );
9355 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9356 let linked = &worktrees[1];
9358 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9359 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9360 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9361 assert_eq!(
9362 linked.get("branch").and_then(Value::as_str),
9363 Some("feature")
9364 );
9365 }
9366
9367 #[tokio::test]
9368 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9369 let main_dir = tempfile::tempdir().unwrap();
9370 let repo = init_repo(main_dir.path());
9371 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9372 repo.set_head("refs/heads/main").unwrap();
9373 let wt_parent = tempfile::tempdir().unwrap();
9375 let wt_path = wt_parent.path().join("feature-wt");
9376 add_worktree(&repo, a, &wt_path, "feature");
9377
9378 let svc = WorktreesService::new();
9379 svc.handle(
9381 "register",
9382 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9383 )
9384 .await
9385 .unwrap();
9386
9387 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9388 assert_eq!(repos.len(), 1);
9389 assert!(repos[0].get("github").is_none(), "no remote → no github");
9390 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9391 let linked = worktrees
9392 .iter()
9393 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9394 .expect("the linked worktree");
9395 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9397 assert!(linked.get("window_key").is_none());
9398 }
9399
9400 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9406 let main_dir = tempfile::tempdir().unwrap();
9407 let repo = init_repo(main_dir.path());
9408 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9409 repo.set_head("refs/heads/trunk").unwrap();
9410 let wt_parent = tempfile::tempdir().unwrap();
9411 let wt_path = wt_parent.path().join("feature-wt");
9412 add_worktree(&repo, a, &wt_path, "feature");
9413 (main_dir, wt_parent, wt_path)
9414 }
9415
9416 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9420 {
9421 let main_dir = tempfile::tempdir().unwrap();
9422 let repo = init_repo(main_dir.path());
9423 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9424 repo.set_head("refs/heads/trunk").unwrap();
9425 let wt_parent = tempfile::tempdir().unwrap();
9426 let first = wt_parent.path().join("first-wt");
9427 let second = wt_parent.path().join("second-wt");
9428 add_worktree(&repo, a, &first, "first");
9429 add_worktree(&repo, a, &second, "second");
9430 (main_dir, wt_parent, first, second)
9431 }
9432
9433 #[tokio::test]
9434 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9435 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9436 let svc = Arc::new(WorktreesService::new());
9437
9438 let close = |path: PathBuf| {
9448 let svc = svc.clone();
9449 async move {
9450 svc.handle(
9451 "close",
9452 json!({ "path": path, "remove": true, "confirmed": true }),
9453 )
9454 .await
9455 }
9456 };
9457 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9458
9459 assert_eq!(a.unwrap(), json!({ "removed": true }));
9460 assert_eq!(b.unwrap(), json!({ "removed": true }));
9461 assert!(!first.exists());
9462 assert!(!second.exists());
9463 let repo = Repository::open(main_dir.path()).unwrap();
9466 assert!(repo.worktrees().unwrap().is_empty());
9467 }
9468
9469 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9476 let repo = init_repo(dir);
9477 let refname = format!("refs/heads/{branch}");
9478 let head = empty_commit(&repo, Some(&refname), &[], "A");
9479 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9480 .unwrap();
9481 repo.set_head(&refname).unwrap();
9482 let mut cfg = repo.config().unwrap();
9483 cfg.set_str("remote.origin.url", url).unwrap();
9484 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9485 .unwrap();
9486 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9487 .unwrap();
9488 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9489 .unwrap();
9490 repo
9491 }
9492
9493 #[test]
9494 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9495 let dir = tempfile::tempdir().unwrap();
9496 let _repo = pushed_github_repo(
9497 dir.path(),
9498 "https://github.com/rust-works/omni-dev.git",
9499 "feature",
9500 );
9501 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9502 assert_eq!(
9503 ok.target,
9504 PrTarget {
9505 owner: "rust-works".into(),
9506 name: "omni-dev".into(),
9507 branch: "feature".into(),
9508 }
9509 );
9510 assert!(!ok.head_sha.is_empty());
9511 }
9512
9513 #[test]
9514 fn evaluate_local_skips_an_unborn_head() {
9515 let dir = tempfile::tempdir().unwrap();
9516 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9518 }
9519
9520 #[test]
9521 fn evaluate_local_skips_a_branch_with_no_upstream() {
9522 let dir = tempfile::tempdir().unwrap();
9523 let repo = init_repo(dir.path());
9524 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9525 repo.set_head("refs/heads/main").unwrap();
9526 repo.config()
9528 .unwrap()
9529 .set_str("remote.origin.url", "https://github.com/o/r.git")
9530 .unwrap();
9531 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9532 }
9533
9534 #[test]
9535 fn evaluate_local_skips_unpushed_local_commits() {
9536 let dir = tempfile::tempdir().unwrap();
9537 let repo = init_repo(dir.path());
9538 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9539 let a_commit = repo.find_commit(a).unwrap();
9540 repo.reference("refs/remotes/origin/main", a, true, "o")
9542 .unwrap();
9543 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9544 drop(a_commit);
9545 repo.set_head("refs/heads/main").unwrap();
9546 let mut cfg = repo.config().unwrap();
9547 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9548 .unwrap();
9549 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9552 .unwrap();
9553 cfg.set_str("branch.main.remote", "origin").unwrap();
9554 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9555 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9556 }
9557
9558 #[test]
9559 fn evaluate_local_skips_a_detached_head() {
9560 let dir = tempfile::tempdir().unwrap();
9561 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9562 let head = repo.head().unwrap().target().unwrap();
9563 repo.set_head_detached(head).unwrap();
9564 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9565 }
9566
9567 #[test]
9568 fn evaluate_local_skips_a_non_github_remote() {
9569 let dir = tempfile::tempdir().unwrap();
9570 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9571 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9572 }
9573
9574 #[test]
9575 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9576 assert_eq!(
9580 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9581 .unwrap_err()
9582 .kind,
9583 "not-a-repo"
9584 );
9585 }
9586
9587 #[test]
9588 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9589 let req = MergeQueueRequest {
9592 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9593 requester_key: Some("win-9".into()),
9594 check: true,
9595 confirmed: false,
9596 };
9597 let logs = capture_info(|| log_merge_check(&req, 1, 1));
9598 assert!(logs.contains("merge-queue check"), "{logs}");
9599 assert!(logs.contains("win-9"), "{logs}");
9600 assert!(logs.contains("requested=2"), "{logs}");
9601 assert!(logs.contains("eligible=1"), "{logs}");
9602 }
9603
9604 #[test]
9605 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9606 let req = MergeQueueRequest {
9608 paths: vec![PathBuf::from("/a")],
9609 requester_key: None,
9610 check: false,
9611 confirmed: true,
9612 };
9613 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9614 assert!(logs.contains("merge-queue enqueue"), "{logs}");
9615 assert!(logs.contains("queued=2"), "{logs}");
9616 assert!(logs.contains("failed=1"), "{logs}");
9617 }
9618
9619 #[test]
9620 fn evaluate_local_flags_dirty_then_untracked() {
9621 let main_dir = tempfile::tempdir().unwrap();
9623 let repo = init_repo(main_dir.path());
9624 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9625 repo.set_head("refs/heads/main").unwrap();
9626 let wt_parent = tempfile::tempdir().unwrap();
9627 let wt_path = wt_parent.path().join("feature-wt");
9628 add_worktree(&repo, a, &wt_path, "feature");
9629
9630 let clean = evaluate_local(&wt_path).unwrap_err();
9632 assert_ne!(clean.kind, "dirty");
9633 assert_ne!(clean.kind, "untracked");
9634
9635 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9637 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9638
9639 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9641 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9642 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9643 }
9644
9645 #[test]
9646 fn is_conflicting_blocks_only_dirty_and_conflicting() {
9647 assert!(is_conflicting(Some("CONFLICTING")));
9648 assert!(is_conflicting(Some("DIRTY")));
9649 assert!(!is_conflicting(Some("CLEAN")));
9650 assert!(!is_conflicting(Some("BLOCKED")));
9651 assert!(!is_conflicting(Some("UNKNOWN")));
9652 assert!(!is_conflicting(None));
9653 }
9654
9655 #[test]
9656 fn merge_queue_request_parses_batch_and_phase_flags() {
9657 let req: MergeQueueRequest = serde_json::from_value(json!({
9658 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9659 }))
9660 .unwrap();
9661 assert_eq!(req.paths.len(), 2);
9662 assert_eq!(req.requester_key.as_deref(), Some("w1"));
9663 assert!(req.confirmed);
9664 assert!(!req.check);
9665 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9667 assert!(req.paths.is_empty());
9668 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9669 }
9670
9671 #[test]
9672 fn queued_pr_omits_already_queued_when_false() {
9673 let v = serde_json::to_value(QueuedPr {
9674 path: "/a".into(),
9675 number: 5,
9676 already_queued: false,
9677 })
9678 .unwrap();
9679 assert!(v.get("already_queued").is_none(), "{v}");
9680 let v = serde_json::to_value(QueuedPr {
9681 path: "/a".into(),
9682 number: 5,
9683 already_queued: true,
9684 })
9685 .unwrap();
9686 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9687 }
9688
9689 #[tokio::test]
9690 async fn merge_queue_check_on_empty_selection_reports_nothing() {
9691 let svc = WorktreesService::new();
9692 let reply = svc
9693 .handle("merge-queue", json!({ "paths": [], "check": true }))
9694 .await
9695 .unwrap();
9696 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9697 }
9698
9699 #[tokio::test]
9700 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9701 let dir = tempfile::tempdir().unwrap();
9704 let _repo = init_repo(dir.path());
9705 let svc = WorktreesService::new();
9706 let reply = svc
9707 .handle(
9708 "merge-queue",
9709 json!({ "paths": [dir.path()], "check": true }),
9710 )
9711 .await
9712 .unwrap();
9713 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9714 assert_eq!(skipped.len(), 1);
9715 assert_eq!(
9716 skipped[0].get("kind").and_then(Value::as_str),
9717 Some("no-commits")
9718 );
9719 assert!(reply
9720 .get("eligible")
9721 .and_then(Value::as_array)
9722 .unwrap()
9723 .is_empty());
9724 }
9725
9726 fn ready_worktree() -> (tempfile::TempDir, String) {
9730 let dir = tempfile::tempdir().unwrap();
9731 let repo = pushed_github_repo(
9732 dir.path(),
9733 "https://github.com/rust-works/omni-dev.git",
9734 "feature",
9735 );
9736 let head = repo.head().unwrap().target().unwrap().to_string();
9737 (dir, head)
9738 }
9739
9740 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9743 format!(
9744 r#"{{"data":{{"r0":{{"b0":{{
9745 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9746 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9747 ]}}}}}},
9748 "associatedPullRequests":{{"nodes":[{pr}]}}
9749 }}}}}}}}"#
9750 )
9751 }
9752
9753 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9757 let ghdir = tempfile::tempdir().unwrap();
9758 let (bin, _shim) = fake_gh(ghdir.path(), reply);
9759 let paths = vec![head_dir.to_path_buf()];
9760 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9761 if let Some(e) = eligible.first() {
9762 return Ok(e.number);
9763 }
9764 Err(skipped.remove(0).kind)
9765 }
9766
9767 #[test]
9768 fn evaluate_batch_marks_a_ready_pr_eligible() {
9769 let (dir, head) = ready_worktree();
9770 let pr = format!(
9771 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9772 );
9773 assert_eq!(
9774 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9775 Ok(9)
9776 );
9777 }
9778
9779 #[test]
9780 fn evaluate_batch_skips_a_draft_pr() {
9781 let (dir, head) = ready_worktree();
9782 let pr = format!(
9783 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9784 );
9785 assert_eq!(
9786 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9787 Err("draft".to_string())
9788 );
9789 }
9790
9791 #[test]
9792 fn evaluate_batch_skips_a_conflicting_pr() {
9793 let (dir, head) = ready_worktree();
9794 let pr = format!(
9795 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9796 );
9797 assert_eq!(
9798 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9799 Err("conflicting".to_string())
9800 );
9801 }
9802
9803 #[test]
9804 fn evaluate_batch_skips_a_pr_with_failing_checks() {
9805 let (dir, head) = ready_worktree();
9806 let pr = format!(
9807 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9808 );
9809 assert_eq!(
9810 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9811 Err("checks-failing".to_string())
9812 );
9813 }
9814
9815 #[test]
9816 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9817 let (dir, head) = ready_worktree();
9818 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9820 assert_eq!(
9821 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9822 Err("stale".to_string())
9823 );
9824 }
9825
9826 #[test]
9827 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9828 let (dir, head) = ready_worktree();
9829 let reply = format!(
9831 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9832 );
9833 assert_eq!(
9834 network_gate_outcome(dir.path(), &reply),
9835 Err("no-pr".to_string())
9836 );
9837 }
9838
9839 #[test]
9840 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9841 let eligible = vec![
9844 Eligible {
9845 path: PathBuf::from("/wt/a"),
9846 number: 1,
9847 url: "u".into(),
9848 branch: "a".into(),
9849 pr_id: "PR_A".into(),
9850 already_queued: true,
9851 },
9852 Eligible {
9853 path: PathBuf::from("/wt/b"),
9854 number: 2,
9855 url: "u".into(),
9856 branch: "b".into(),
9857 pr_id: "PR_B".into(),
9858 already_queued: false,
9859 },
9860 ];
9861 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9862 assert_eq!(queued.len(), 1);
9863 assert_eq!(queued[0].number, 1);
9864 assert!(queued[0].already_queued);
9865 assert_eq!(failed.len(), 1);
9866 assert_eq!(failed[0].number, 2);
9867 }
9868
9869 #[test]
9870 fn enqueue_eligible_records_a_github_rejection_as_failed() {
9871 let ghdir = tempfile::tempdir().unwrap();
9875 let (bin, _shim) = fake_gh(
9876 ghdir.path(),
9877 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9878 );
9879 let eligible = vec![Eligible {
9880 path: PathBuf::from("/wt/a"),
9881 number: 7,
9882 url: "u".into(),
9883 branch: "a".into(),
9884 pr_id: "PR_A".into(),
9885 already_queued: false,
9886 }];
9887 let (queued, failed) = enqueue_eligible(&bin, eligible);
9888 assert!(queued.is_empty(), "{queued:?}");
9889 assert_eq!(failed.len(), 1);
9890 assert_eq!(failed[0].number, 7);
9891 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9894 }
9895
9896 #[tokio::test]
9897 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9899 let (dir, head) = ready_worktree();
9902 let pr = format!(
9903 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9904 );
9905 let ghdir = tempfile::tempdir().unwrap();
9906 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9907 let svc = WorktreesService::new();
9908 let reply = svc
9909 .merge_queue_with(
9910 MergeQueueRequest {
9911 paths: vec![dir.path().to_path_buf()],
9912 requester_key: None,
9913 check: true,
9914 confirmed: false,
9915 },
9916 bin,
9917 )
9918 .await
9919 .unwrap();
9920 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9921 assert_eq!(eligible.len(), 1);
9922 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9923 assert_eq!(
9924 eligible[0].get("branch").and_then(Value::as_str),
9925 Some("feature")
9926 );
9927 }
9928
9929 #[tokio::test]
9930 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9932 let (dir, head) = ready_worktree();
9935 let pr = format!(
9936 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9937 );
9938 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9939 let ghdir = tempfile::tempdir().unwrap();
9940 let guard = shim_lock();
9941 let bin = ghdir.path().join("fake-gh");
9942 write_exec_script(
9943 &bin,
9944 &format!(
9945 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
9946 enqueue =
9947 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9948 ),
9949 );
9950 let svc = WorktreesService::new();
9951 let reply = svc
9952 .merge_queue_with(
9953 MergeQueueRequest {
9954 paths: vec![dir.path().to_path_buf()],
9955 requester_key: Some("w1".into()),
9956 check: false,
9957 confirmed: true,
9958 },
9959 bin,
9960 )
9961 .await
9962 .unwrap();
9963 drop(guard);
9964 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9965 assert_eq!(queued.len(), 1, "{reply}");
9966 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9967 assert!(reply
9968 .get("failed")
9969 .and_then(Value::as_array)
9970 .unwrap()
9971 .is_empty());
9972 }
9973
9974 #[tokio::test]
9975 async fn concurrent_closes_overlap_their_heartbeat_waits() {
9976 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9977 let svc = Arc::new(WorktreesService::new());
9978 for (key, path) in [("w2", &first), ("w3", &second)] {
9980 svc.handle("register", json!({ "key": key, "folders": [path] }))
9981 .await
9982 .unwrap();
9983 }
9984
9985 let spawn_close = |path: PathBuf| {
9986 let svc = svc.clone();
9987 tokio::spawn(async move {
9988 svc.handle(
9989 "close",
9990 json!({
9991 "path": path,
9992 "remove": true,
9993 "confirmed": true,
9994 "requester_key": "w1",
9995 }),
9996 )
9997 .await
9998 })
9999 };
10000 let a = spawn_close(first.clone());
10001 let b = spawn_close(second.clone());
10002
10003 for key in ["w2", "w3"] {
10010 let mut saw_close = false;
10011 for _ in 0..400 {
10012 let hb = svc
10013 .handle("heartbeat", json!({ "key": key }))
10014 .await
10015 .unwrap();
10016 if hb.get("close").and_then(Value::as_bool) == Some(true) {
10017 saw_close = true;
10018 break;
10019 }
10020 tokio::time::sleep(Duration::from_millis(5)).await;
10021 }
10022 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10023 }
10024 assert!(
10025 !a.is_finished() && !b.is_finished(),
10026 "neither close can have finished: both windows are still registered"
10027 );
10028
10029 for key in ["w2", "w3"] {
10031 svc.handle("unregister", json!({ "key": key }))
10032 .await
10033 .unwrap();
10034 }
10035 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10036 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10037 assert!(!first.exists());
10038 assert!(!second.exists());
10039 }
10040
10041 #[tokio::test]
10042 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10043 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10044 let svc = WorktreesService::new();
10045 let report = svc
10048 .handle("close", json!({ "path": wt_path, "remove": true }))
10049 .await
10050 .unwrap();
10051 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10052 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10053 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10054 assert!(report
10055 .get("risks")
10056 .and_then(Value::as_array)
10057 .unwrap()
10058 .is_empty());
10059 assert!(wt_path.exists());
10061 }
10062
10063 #[tokio::test]
10064 async fn close_removes_a_clean_linked_worktree() {
10065 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10066 let svc = WorktreesService::new();
10067 let reply = svc
10068 .handle(
10069 "close",
10070 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10071 )
10072 .await
10073 .unwrap();
10074 assert_eq!(reply, json!({ "removed": true }));
10075 assert!(
10076 !wt_path.exists(),
10077 "the worktree directory should be deleted"
10078 );
10079 }
10080
10081 #[derive(Clone, Default)]
10091 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10092
10093 impl std::io::Write for CaptureWriter {
10094 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10095 self.0.lock().unwrap().extend_from_slice(buf);
10096 Ok(buf.len())
10097 }
10098 fn flush(&mut self) -> std::io::Result<()> {
10099 Ok(())
10100 }
10101 }
10102
10103 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10104 type Writer = Self;
10105 fn make_writer(&'a self) -> Self::Writer {
10106 self.clone()
10107 }
10108 }
10109
10110 fn capture_info(f: impl FnOnce()) -> String {
10113 let writer = CaptureWriter::default();
10114 let subscriber = tracing_subscriber::fmt()
10115 .with_max_level(tracing::Level::INFO)
10116 .with_ansi(false)
10117 .with_writer(writer.clone())
10118 .finish();
10119 tracing::subscriber::with_default(subscriber, f);
10120 let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10121 logs
10122 }
10123
10124 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10128 RebaseRequest {
10129 paths,
10130 requester_key: None,
10131 check: false,
10132 confirmed: false,
10133 keep_conflicts: false,
10134 autostash: false,
10135 onto: None,
10136 }
10137 }
10138
10139 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10147 let main_dir = tempfile::tempdir().unwrap();
10148 let repo = init_repo(main_dir.path());
10149 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10150 repo.set_head("refs/heads/main").unwrap();
10151 let wt_parent = tempfile::tempdir().unwrap();
10152 let wt_path = wt_parent.path().join("feature-wt");
10153 add_worktree(&repo, base, &wt_path, "feature");
10154 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10156 (main_dir, wt_parent, wt_path)
10157 }
10158
10159 #[tokio::test]
10160 async fn rebase_with_refuses_an_empty_selection() {
10161 let svc = WorktreesService::new();
10163 let err = svc
10164 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10165 .await
10166 .unwrap_err()
10167 .to_string();
10168 assert!(err.contains("at least one path"), "{err}");
10169 }
10170
10171 #[tokio::test]
10172 async fn rebase_with_phase_one_reports_without_rebasing() {
10173 let (_main, _parent, wt) = behind_worktree();
10174 let before = Repository::open(&wt).unwrap().head().unwrap().target();
10175
10176 let svc = WorktreesService::new();
10177 let reply = svc
10178 .rebase_with(
10179 RebaseRequest {
10180 check: true,
10181 onto: Some("main".into()),
10182 ..rebase_req(vec![wt.clone()])
10183 },
10184 crate::git::resolve_git_binary(),
10185 )
10186 .await
10187 .unwrap();
10188
10189 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10190 assert_eq!(worktrees.len(), 1, "{reply}");
10191 assert_eq!(
10192 worktrees[0].get("status").and_then(Value::as_str),
10193 Some("would-rebase"),
10194 "{reply}"
10195 );
10196 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10198 assert_eq!(fetches.len(), 1);
10199 assert_eq!(
10200 fetches[0].get("fetched").and_then(Value::as_bool),
10201 Some(false)
10202 );
10203 assert_eq!(
10204 Repository::open(&wt).unwrap().head().unwrap().target(),
10205 before,
10206 "phase 1 must not move the branch"
10207 );
10208 }
10209
10210 #[tokio::test]
10211 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10212 let (_main, _parent, wt) = behind_worktree();
10213 let svc = WorktreesService::new();
10214 let reply = svc
10215 .rebase_with(
10216 RebaseRequest {
10217 confirmed: true,
10218 onto: Some("main".into()),
10219 ..rebase_req(vec![wt.clone()])
10220 },
10221 crate::git::resolve_git_binary(),
10222 )
10223 .await
10224 .unwrap();
10225
10226 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10227 assert_eq!(
10228 worktrees[0].get("status").and_then(Value::as_str),
10229 Some("rebased"),
10230 "{reply}"
10231 );
10232 assert!(
10234 svc.registry.rebasing_paths().is_empty(),
10235 "the rebasing mark must be cleared after the execute"
10236 );
10237 }
10238
10239 #[tokio::test]
10240 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10241 let (_main, _parent, wt) = behind_worktree();
10245 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10246
10247 let svc = WorktreesService::new();
10248 let reply = svc
10249 .rebase_with(
10250 RebaseRequest {
10251 confirmed: true,
10252 onto: Some("main".into()),
10253 ..rebase_req(vec![wt.clone()])
10254 },
10255 crate::git::resolve_git_binary(),
10256 )
10257 .await
10258 .unwrap();
10259
10260 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10261 assert_eq!(
10262 worktrees[0].get("status").and_then(Value::as_str),
10263 Some("skipped"),
10264 "{reply}"
10265 );
10266 assert_eq!(
10267 worktrees[0].get("reason").and_then(Value::as_str),
10268 Some("dirty"),
10269 "{reply}"
10270 );
10271 }
10272
10273 #[tokio::test]
10274 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10275 let (_main, _parent, wt) = behind_worktree();
10281 std::fs::create_dir_all(wt.join(".git")).ok();
10284 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10285 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10286 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10287 assert_ne!(
10288 Repository::open(&wt).unwrap().state(),
10289 RepositoryState::Clean,
10290 "precondition: the worktree looks mid-rebase to git2"
10291 );
10292
10293 let svc = WorktreesService::new();
10294 let reply = svc
10295 .rebase_with(
10296 RebaseRequest {
10297 confirmed: true,
10298 onto: Some("main".into()),
10299 ..rebase_req(vec![wt.clone()])
10300 },
10301 crate::git::resolve_git_binary(),
10302 )
10303 .await
10304 .unwrap();
10305
10306 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10307 assert_eq!(
10308 worktrees[0].get("reason").and_then(Value::as_str),
10309 Some("operation-in-progress"),
10310 "{reply}"
10311 );
10312 assert_ne!(
10314 Repository::open(&wt).unwrap().state(),
10315 RepositoryState::Clean
10316 );
10317 }
10318
10319 #[test]
10320 fn rebase_request_maps_onto_engine_options() {
10321 let req = RebaseRequest {
10322 keep_conflicts: true,
10323 autostash: true,
10324 onto: Some("origin/release".into()),
10325 ..rebase_req(vec![PathBuf::from("/wt")])
10326 };
10327 let opts = req.options(PathBuf::from("/custom/git"));
10328 assert!(opts.keep_conflicts && opts.autostash);
10329 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10330 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10331 assert!(!opts.dry_run);
10335 }
10336
10337 #[test]
10338 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10339 let req = RebaseRequest {
10340 requester_key: Some("win-3".into()),
10341 check: true,
10342 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10343 };
10344 let plan = worktree_rebase::Plan {
10345 fetches: vec![worktree_rebase::FetchOutcome {
10346 repo_root: PathBuf::from("/repo"),
10347 onto: "origin/main".into(),
10348 fetched: true,
10349 ok: false,
10350 detail: Some("host unreachable".into()),
10351 }],
10352 worktrees: vec![
10353 worktree_rebase::WorktreeOutcome {
10354 path: PathBuf::from("/a"),
10355 branch: Some("a".into()),
10356 onto: "origin/main".into(),
10357 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10358 },
10359 worktree_rebase::WorktreeOutcome {
10360 path: PathBuf::from("/b"),
10361 branch: Some("b".into()),
10362 onto: "origin/main".into(),
10363 result: worktree_rebase::RebaseResult::UpToDate,
10364 },
10365 ],
10366 };
10367 let logs = capture_info(|| log_rebase_check(&req, &plan));
10368 assert!(logs.contains("rebase check"), "{logs}");
10369 assert!(logs.contains("win-3"), "{logs}");
10370 assert!(logs.contains("requested=2"), "{logs}");
10371 assert!(logs.contains("pending=1"), "{logs}");
10372 assert!(logs.contains("failed_fetches=1"), "{logs}");
10373 }
10374
10375 #[test]
10376 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10377 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10379 let outcome = |result| worktree_rebase::WorktreeOutcome {
10380 path: PathBuf::from("/x"),
10381 branch: Some("x".into()),
10382 onto: "origin/main".into(),
10383 result,
10384 };
10385 let outcomes = vec![
10386 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10387 outcome(worktree_rebase::RebaseResult::Conflict {
10388 detail: "CONFLICT".into(),
10389 left_in_place: true,
10390 }),
10391 outcome(worktree_rebase::RebaseResult::Skipped {
10392 reason: worktree_rebase::SkipReason::Dirty,
10393 }),
10394 ];
10395 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10396 assert!(logs.contains("rebase execute"), "{logs}");
10397 assert!(logs.contains("rebased=1"), "{logs}");
10398 assert!(logs.contains("conflicts=1"), "{logs}");
10399 assert!(logs.contains("left_in_place=1"), "{logs}");
10400 assert!(logs.contains("skipped=1"), "{logs}");
10401 assert!(logs.contains(r#"requester="-""#), "{logs}");
10402 }
10403
10404 fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10408 PushRequest {
10409 paths,
10410 requester_key: None,
10411 check: false,
10412 confirmed: false,
10413 }
10414 }
10415
10416 fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10423 let _guard = crate::git::worktree_batch::test_serial_lock();
10426 let root = tempfile::tempdir().unwrap();
10427 let origin = root.path().join("origin.git");
10428 let local = root.path().join("local");
10429 let wt = root.path().join("feature-wt");
10430 std::fs::create_dir_all(&origin).unwrap();
10431 std::fs::create_dir_all(&local).unwrap();
10432
10433 let git = |dir: &Path, args: &[&str]| {
10434 let out = crate::git::worktree_batch::run_git_in(
10435 &crate::git::resolve_git_binary(),
10436 dir,
10437 args,
10438 )
10439 .unwrap();
10440 assert!(
10441 out.status.success(),
10442 "git {args:?} failed: {}",
10443 String::from_utf8_lossy(&out.stderr)
10444 );
10445 };
10446
10447 git(&origin, &["init", "--bare", "-b", "main"]);
10448 git(&local, &["init", "-b", "main"]);
10449 git(&local, &["config", "user.name", "Test"]);
10450 git(&local, &["config", "user.email", "test@example.com"]);
10451 git(&local, &["config", "commit.gpgsign", "false"]);
10452 std::fs::write(local.join("f.txt"), "base\n").unwrap();
10453 git(&local, &["add", "f.txt"]);
10454 git(&local, &["commit", "-m", "base"]);
10455 git(
10456 &local,
10457 &["remote", "add", "origin", origin.to_str().unwrap()],
10458 );
10459 git(&local, &["push", "-u", "origin", "main"]);
10460 git(
10461 &local,
10462 &[
10463 "worktree",
10464 "add",
10465 "-b",
10466 "feature",
10467 wt.to_str().unwrap(),
10468 "main",
10469 ],
10470 );
10471 std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10472 git(&wt, &["add", "g.txt"]);
10473 git(&wt, &["commit", "-m", "work"]);
10474 git(&wt, &["push", "-u", "origin", "feature"]);
10475 git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10477
10478 (root, origin, std::fs::canonicalize(&wt).unwrap())
10479 }
10480
10481 fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10483 Repository::open_bare(origin)
10484 .unwrap()
10485 .refname_to_id(refname)
10486 .ok()
10487 }
10488
10489 #[tokio::test]
10490 async fn push_with_refuses_an_empty_selection() {
10491 let svc = WorktreesService::new();
10493 let err = svc
10494 .push_with(push_req(Vec::new()), PathBuf::from("git"))
10495 .await
10496 .unwrap_err()
10497 .to_string();
10498 assert!(err.contains("at least one path"), "{err}");
10499 }
10500
10501 #[tokio::test]
10502 async fn push_with_phase_one_reports_without_publishing() {
10503 let (_root, origin, wt) = rewritten_worktree();
10504 let before = origin_tip(&origin, "refs/heads/feature");
10505
10506 let svc = WorktreesService::new();
10507 let reply = svc
10508 .push_with(
10509 PushRequest {
10510 check: true,
10511 ..push_req(vec![wt.clone()])
10512 },
10513 crate::git::resolve_git_binary(),
10514 )
10515 .await
10516 .unwrap();
10517
10518 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10519 assert_eq!(worktrees.len(), 1, "{reply}");
10520 assert_eq!(
10521 worktrees[0].get("status").and_then(Value::as_str),
10522 Some("would-force"),
10523 "{reply}"
10524 );
10525 assert!(
10526 reply.get("fetches").is_none(),
10527 "a push plan contacts no remote, so it reports no fetches: {reply}"
10528 );
10529 assert_eq!(
10530 origin_tip(&origin, "refs/heads/feature"),
10531 before,
10532 "phase 1 must publish nothing"
10533 );
10534 assert!(
10535 svc.registry.pushing_paths().is_empty(),
10536 "phase 1 must not mark a row as in flight"
10537 );
10538 }
10539
10540 #[tokio::test]
10541 async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10542 let (_root, origin, wt) = rewritten_worktree();
10543 let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10544
10545 let svc = WorktreesService::new();
10546 let reply = svc
10547 .push_with(
10548 PushRequest {
10549 confirmed: true,
10550 ..push_req(vec![wt.clone()])
10551 },
10552 crate::git::resolve_git_binary(),
10553 )
10554 .await
10555 .unwrap();
10556
10557 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10558 assert_eq!(
10559 worktrees[0].get("status").and_then(Value::as_str),
10560 Some("pushed"),
10561 "{reply}"
10562 );
10563 assert_eq!(
10564 worktrees[0].get("forced").and_then(Value::as_bool),
10565 Some(true),
10566 "a rewritten branch is published under the lease: {reply}"
10567 );
10568 assert_eq!(
10569 origin_tip(&origin, "refs/heads/feature"),
10570 rewritten,
10571 "the remote must carry the rewritten tip"
10572 );
10573 assert!(
10574 svc.registry.pushing_paths().is_empty(),
10575 "the pushing mark must be cleared after the execute — a push writes no \
10576 on-disk state, so nothing else could ever correct a leftover"
10577 );
10578 }
10579
10580 #[tokio::test]
10581 async fn push_resolves_the_git_binary_for_itself() {
10582 let (_root, origin, wt) = rewritten_worktree();
10586 let before = origin_tip(&origin, "refs/heads/feature");
10587
10588 let svc = WorktreesService::new();
10589 let reply = svc
10590 .push(PushRequest {
10591 check: true,
10592 ..push_req(vec![wt])
10593 })
10594 .await
10595 .unwrap();
10596
10597 assert_eq!(
10598 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10599 .get("status")
10600 .and_then(Value::as_str),
10601 Some("would-force"),
10602 "{reply}"
10603 );
10604 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10605 }
10606
10607 #[tokio::test]
10608 async fn push_with_defaults_to_report_only_without_confirmation() {
10609 let (_root, origin, wt) = rewritten_worktree();
10612 let before = origin_tip(&origin, "refs/heads/feature");
10613
10614 let svc = WorktreesService::new();
10615 let reply = svc
10616 .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10617 .await
10618 .unwrap();
10619
10620 assert_eq!(
10621 reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10622 .get("status")
10623 .and_then(Value::as_str),
10624 Some("would-force"),
10625 "{reply}"
10626 );
10627 assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10628 }
10629
10630 #[tokio::test]
10631 async fn push_with_refuses_to_force_the_remote_default_branch() {
10632 let (root, origin, _wt) = rewritten_worktree();
10635 let local = root.path().join("local");
10636 let before = origin_tip(&origin, "refs/heads/main");
10637 crate::git::worktree_batch::run_git_in(
10638 &crate::git::resolve_git_binary(),
10639 &local,
10640 &["commit", "--amend", "-m", "rewritten main"],
10641 )
10642 .unwrap();
10643
10644 let svc = WorktreesService::new();
10645 let reply = svc
10646 .push_with(
10647 PushRequest {
10648 confirmed: true,
10649 ..push_req(vec![local.clone()])
10650 },
10651 crate::git::resolve_git_binary(),
10652 )
10653 .await
10654 .unwrap();
10655
10656 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10657 assert_eq!(
10658 worktrees[0].get("reason").and_then(Value::as_str),
10659 Some("default-branch-force-push"),
10660 "{reply}"
10661 );
10662 assert_eq!(
10663 origin_tip(&origin, "refs/heads/main"),
10664 before,
10665 "the default branch's published history must be untouched"
10666 );
10667 }
10668
10669 #[test]
10670 fn log_push_check_separates_the_force_count_from_the_pending_count() {
10671 let req = PushRequest {
10672 requester_key: Some("win-7".into()),
10673 check: true,
10674 ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10675 };
10676 let outcome = |result| worktree_push::WorktreeOutcome {
10677 path: PathBuf::from("/x"),
10678 branch: Some("x".into()),
10679 remote: "origin".into(),
10680 remote_branch: "x".into(),
10681 result,
10682 };
10683 let plan = worktree_push::Plan {
10684 worktrees: vec![
10685 outcome(worktree_push::PushResult::WouldForce {
10686 ahead: 1,
10687 behind: 1,
10688 }),
10689 outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10690 outcome(worktree_push::PushResult::Skipped {
10691 reason: worktree_push::SkipReason::DefaultBranchForcePush,
10692 }),
10693 ],
10694 };
10695 let logs = capture_info(|| log_push_check(&req, &plan));
10696 assert!(logs.contains("push check"), "{logs}");
10697 assert!(logs.contains("pending=2"), "{logs}");
10698 assert!(logs.contains("forced=1"), "{logs}");
10699 assert!(logs.contains("skipped=1"), "{logs}");
10700 assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10701 }
10702
10703 #[test]
10704 fn log_push_execute_counts_lease_refusals_separately() {
10705 let req = push_req(vec![PathBuf::from("/a")]);
10706 let outcome = |result| worktree_push::WorktreeOutcome {
10707 path: PathBuf::from("/x"),
10708 branch: Some("x".into()),
10709 remote: "origin".into(),
10710 remote_branch: "x".into(),
10711 result,
10712 };
10713 let outcomes = vec![
10714 outcome(worktree_push::PushResult::Pushed { forced: true }),
10715 outcome(worktree_push::PushResult::Pushed { forced: false }),
10716 outcome(worktree_push::PushResult::Created),
10717 outcome(worktree_push::PushResult::Rejected {
10718 detail: "stale info".into(),
10719 stale: true,
10720 }),
10721 outcome(worktree_push::PushResult::Rejected {
10722 detail: "pre-receive hook declined".into(),
10723 stale: false,
10724 }),
10725 ];
10726 let logs = capture_info(|| log_push_execute(&req, &outcomes));
10727 assert!(logs.contains("push execute"), "{logs}");
10728 assert!(logs.contains("pushed=2"), "{logs}");
10729 assert!(logs.contains("forced=1"), "{logs}");
10730 assert!(logs.contains("created=1"), "{logs}");
10731 assert!(logs.contains("rejected=2"), "{logs}");
10732 assert!(
10733 logs.contains("stale_rejected=1"),
10734 "a lease refusal is the interesting half of a rejection: {logs}"
10735 );
10736 }
10737
10738 #[test]
10739 fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10740 let dir = tempfile::tempdir().unwrap();
10741 let path = canonical(dir.path());
10742
10743 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10744 assert!(!quiet.pushing);
10745 let json = serde_json::to_value(&quiet).unwrap();
10746 assert!(
10747 json.get("pushing").is_none(),
10748 "an idle row stays byte-identical for an older client: {json}"
10749 );
10750
10751 let busy = worktree_entry(
10752 &path,
10753 true,
10754 &HashMap::new(),
10755 &InFlight {
10756 pushing: [path.clone()].into(),
10757 rebasing: HashSet::new(),
10758 },
10759 );
10760 assert!(busy.pushing, "the registry's transient mark rides through");
10761 assert!(
10762 !busy.rebasing,
10763 "the two cues are independent — a push must not read as a rebase"
10764 );
10765 assert_eq!(
10766 serde_json::to_value(&busy).unwrap()["pushing"],
10767 serde_json::json!(true)
10768 );
10769 }
10770
10771 #[test]
10772 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10773 assert_eq!(operation_slug(RepositoryState::Clean), None);
10774 assert_eq!(
10775 operation_slug(RepositoryState::Rebase).as_deref(),
10776 Some("rebase")
10777 );
10778 assert_eq!(
10779 operation_slug(RepositoryState::RebaseMerge).as_deref(),
10780 Some("rebase"),
10781 "the merge-backend rebase is still just a rebase to the user"
10782 );
10783 assert_eq!(
10784 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10785 Some("rebase-interactive")
10786 );
10787 assert_eq!(
10788 operation_slug(RepositoryState::Merge).as_deref(),
10789 Some("merge")
10790 );
10791 assert_eq!(
10792 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10793 Some("cherry-pick")
10794 );
10795 assert_eq!(
10796 operation_slug(RepositoryState::RevertSequence).as_deref(),
10797 Some("revert")
10798 );
10799 assert_eq!(
10800 operation_slug(RepositoryState::Bisect).as_deref(),
10801 Some("bisect")
10802 );
10803 assert_eq!(
10804 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10805 Some("apply-mailbox")
10806 );
10807 }
10808
10809 #[test]
10810 fn git_status_omits_operation_for_a_clean_worktree() {
10811 let dir = tempfile::tempdir().unwrap();
10812 let _repo = diverging_repo(dir.path());
10813 assert_eq!(
10814 git_status(dir.path()).operation,
10815 None,
10816 "a clean worktree carries no operation, so the field stays off the wire"
10817 );
10818 }
10819
10820 #[test]
10821 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10822 let main_dir = tempfile::tempdir().unwrap();
10823 let repo = init_repo(main_dir.path());
10824 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10825 repo.set_head("refs/heads/main").unwrap();
10826 let path = canonical(main_dir.path());
10827
10828 let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10829 assert!(!quiet.rebasing);
10830 let json = serde_json::to_value(&quiet).unwrap();
10832 assert!(json.get("rebasing").is_none(), "{json}");
10833 assert!(json.get("operation").is_none(), "{json}");
10834
10835 let busy = worktree_entry(
10836 &path,
10837 true,
10838 &HashMap::new(),
10839 &InFlight {
10840 rebasing: std::iter::once(path.clone()).collect(),
10841 pushing: HashSet::new(),
10842 },
10843 );
10844 assert!(busy.rebasing, "the registry's transient mark rides through");
10845 assert_eq!(
10846 serde_json::to_value(&busy).unwrap()["rebasing"],
10847 serde_json::Value::Bool(true)
10848 );
10849 }
10850
10851 #[test]
10852 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10853 assert_eq!(note_kinds(&[]), "-");
10854 assert_eq!(
10855 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10856 "dirty,untracked"
10857 );
10858 }
10859
10860 #[test]
10861 fn is_self_close_true_only_when_requester_owns_an_open_window() {
10862 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10863 assert!(is_self_close(Some("w1"), &windows));
10864 assert!(
10865 !is_self_close(Some("w3"), &windows),
10866 "requester owns no window"
10867 );
10868 assert!(!is_self_close(None, &windows), "no requester");
10869 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10870 }
10871
10872 #[test]
10873 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10874 let logs = capture_info(|| {
10875 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10876 assert_eq!(reply, json!({ "removed": true }));
10877 });
10878 assert!(
10879 logs.contains("worktrees close: linked worktree pruned"),
10880 "a successful prune must log an INFO audit line, got: {logs}"
10881 );
10882 assert!(
10883 logs.contains("/wt/feature"),
10884 "the target path must ride the line, got: {logs}"
10885 );
10886 }
10887
10888 #[test]
10889 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10890 let logs = capture_info(|| {
10895 let reply =
10896 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10897 assert_eq!(reply, json!({ "removed": true }));
10898 });
10899 assert!(
10900 logs.contains("worktrees close: nothing to prune, worktree already removed"),
10901 "an already-gone close must log its own outcome, got: {logs}"
10902 );
10903 assert!(
10904 !logs.contains("linked worktree pruned"),
10905 "an already-gone close must not claim it pruned, got: {logs}"
10906 );
10907 }
10908
10909 #[test]
10910 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10911 let logs = capture_info(|| {
10913 let err = log_close_error(
10914 Path::new("/wt/feature"),
10915 "safety check",
10916 anyhow!("not a git worktree"),
10917 );
10918 assert_eq!(
10919 err.to_string(),
10920 "not a git worktree",
10921 "err propagates unchanged"
10922 );
10923 });
10924 assert!(
10925 logs.contains("worktrees close: safety check failed"),
10926 "a failed phase must log an ERROR audit line, got: {logs}"
10927 );
10928 assert!(
10929 logs.contains("not a git worktree"),
10930 "the cause must ride the line, got: {logs}"
10931 );
10932 assert!(
10933 logs.contains("/wt/feature"),
10934 "the target path must ride the line, got: {logs}"
10935 );
10936 }
10937
10938 #[test]
10939 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10940 let logs = capture_info(|| {
10941 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10942 assert!(err.is_err(), "a prune failure must propagate");
10943 });
10944 assert!(
10945 logs.contains("worktrees close: worktree prune failed"),
10946 "a prune failure must log a WARN audit line, got: {logs}"
10947 );
10948 assert!(
10949 logs.contains("locked"),
10950 "the failure cause must ride the line, got: {logs}"
10951 );
10952 }
10953
10954 #[test]
10955 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10956 let git = GitSafety {
10957 is_main: false,
10958 removable: true,
10959 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10960 info: vec![],
10961 };
10962 let logs = capture_info(|| {
10963 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10964 });
10965 assert!(
10966 logs.contains("worktrees close: safety check"),
10967 "phase-1 must log a safety-check line, got: {logs}"
10968 );
10969 assert!(
10970 logs.contains("/wt/feature"),
10971 "the path must ride the line, got: {logs}"
10972 );
10973 assert!(
10974 logs.contains("window_key=\"win-42\""),
10975 "the owning window key must ride the line, got: {logs}"
10976 );
10977 assert!(logs.contains("removable=true"), "got: {logs}");
10978 assert!(logs.contains("is_main=false"), "got: {logs}");
10979 assert!(logs.contains("open=true"), "got: {logs}");
10980 assert!(
10981 logs.contains("risks=dirty,untracked"),
10982 "the blocking risk kinds must ride the line, got: {logs}"
10983 );
10984 }
10985
10986 #[test]
10987 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10988 let git = GitSafety {
10989 is_main: false,
10990 removable: true,
10991 risks: vec![],
10992 info: vec![],
10993 };
10994 let logs = capture_info(|| {
10995 log_safety_check(Path::new("/wt/feature"), None, &git, false);
10996 });
10997 assert!(
10998 logs.contains("window_key=\"-\""),
10999 "no owning window → dash, got: {logs}"
11000 );
11001 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11002 }
11003
11004 #[test]
11005 fn log_executing_logs_the_routing_decision() {
11006 let logs = capture_info(|| {
11007 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11008 });
11009 assert!(
11010 logs.contains("worktrees close: executing"),
11011 "phase-2 must log the execute routing, got: {logs}"
11012 );
11013 assert!(
11014 logs.contains("requester=\"win-7\""),
11015 "the requester key must ride the line, got: {logs}"
11016 );
11017 assert!(logs.contains("remove=true"), "got: {logs}");
11018 assert!(logs.contains("self_close=false"), "got: {logs}");
11019 assert!(logs.contains("cross_window=3"), "got: {logs}");
11020 }
11021
11022 #[test]
11023 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11024 let logs = capture_info(|| {
11025 log_close_abort(
11026 Path::new("/wt/feature"),
11027 &anyhow!("window(s) did not close in time: win-9"),
11028 );
11029 });
11030 assert!(
11031 logs.contains("worktrees close: aborted"),
11032 "an abort must log a WARN audit line, got: {logs}"
11033 );
11034 assert!(
11035 logs.contains("/wt/feature"),
11036 "the path must ride the line, got: {logs}"
11037 );
11038 assert!(
11039 logs.contains("win-9"),
11040 "the still-open window must ride the line, got: {logs}"
11041 );
11042 }
11043
11044 #[test]
11045 fn log_window_closed_logs_the_no_removal_outcome() {
11046 let logs = capture_info(|| {
11047 log_window_closed(Path::new("/wt/feature"));
11048 });
11049 assert!(
11050 logs.contains("worktrees close: window closed, no removal"),
11051 "a remove:false close must log the no-removal outcome, got: {logs}"
11052 );
11053 assert!(
11054 logs.contains("/wt/feature"),
11055 "the path must ride the line, got: {logs}"
11056 );
11057 }
11058
11059 #[test]
11060 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11061 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11065 let admin = main.path().join(".git").join("worktrees").join("feature");
11066 assert!(admin.exists(), "admin metadata should exist before removal");
11067
11068 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11069
11070 assert!(!wt_path.exists(), "the working directory should be gone");
11071 assert!(!admin.exists(), "the admin metadata should be pruned");
11072 let main_repo = Repository::open(main.path()).unwrap();
11073 assert_eq!(
11074 main_repo.worktrees().unwrap().len(),
11075 0,
11076 "git should no longer track the worktree"
11077 );
11078 }
11079
11080 #[test]
11081 fn remove_worktree_recovers_a_half_removed_orphan() {
11082 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11087 let admin = main.path().join(".git").join("worktrees").join("feature");
11088 std::fs::remove_dir_all(&admin).unwrap();
11090 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11091 assert!(
11092 Repository::open(&wt_path).is_err(),
11093 "the orphan should not open as a repo"
11094 );
11095
11096 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11097 assert!(
11098 !wt_path.exists(),
11099 "the leftover directory should be removed"
11100 );
11101 }
11102
11103 fn orphaned_admin_worktree() -> (
11111 tempfile::TempDir,
11112 PathBuf,
11113 tempfile::TempDir,
11114 PathBuf,
11115 PathBuf,
11116 ) {
11117 let main_dir = tempfile::tempdir().unwrap();
11118 let main_root = main_dir.path().canonicalize().unwrap();
11119 let repo = init_repo(&main_root);
11120 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11121 repo.set_head("refs/heads/trunk").unwrap();
11122 let wt_parent = tempfile::tempdir().unwrap();
11123 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11124 add_worktree(&repo, a, &wt_path, "feature");
11125 let admin = main_root.join(".git").join("worktrees").join("feature");
11126 assert!(admin.exists(), "admin metadata exists before the orphaning");
11127 std::fs::remove_dir_all(&wt_path).unwrap();
11129 (main_dir, main_root, wt_parent, wt_path, admin)
11130 }
11131
11132 fn window_on(folder: &Path) -> WindowEntry {
11133 WindowEntry {
11134 key: "w".to_string(),
11135 folders: vec![folder.to_path_buf()],
11136 repo: None,
11137 title: None,
11138 pid: None,
11139 last_seen: Utc::now(),
11140 }
11141 }
11142
11143 #[test]
11144 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11145 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11149
11150 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11151
11152 assert_eq!(
11153 removed,
11154 Removal::Pruned,
11155 "the orphaned admin must be pruned"
11156 );
11157 assert!(!admin.exists(), "the admin metadata should be gone");
11158 let main_repo = Repository::open(&main_root).unwrap();
11159 assert!(
11160 main_repo.worktrees().unwrap().is_empty(),
11161 "git should no longer track the orphaned worktree"
11162 );
11163 }
11164
11165 #[test]
11166 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11167 let main_dir = tempfile::tempdir().unwrap();
11171 let main_root = main_dir.path().canonicalize().unwrap();
11172 let repo = init_repo(&main_root);
11173 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11174 repo.set_head("refs/heads/trunk").unwrap();
11175 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11177 let wt_path = main_root.join(".nested").join("feature-wt");
11178 add_worktree(&repo, a, &wt_path, "feature");
11179 let admin = main_root.join(".git").join("worktrees").join("feature");
11180 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11181
11182 let removed = remove_worktree(&wt_path, &[]).unwrap();
11183
11184 assert_eq!(removed, Removal::Pruned);
11185 assert!(!admin.exists(), "the admin metadata should be gone");
11186 }
11187
11188 #[test]
11189 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11190 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11194 std::fs::remove_dir_all(&admin).unwrap();
11196
11197 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11198
11199 assert_eq!(removed, Removal::AlreadyGone);
11200 }
11201
11202 #[test]
11203 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11204 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11205 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11208 assert!(
11209 roots.contains(&main_root),
11210 "the owning main repo must be a candidate, got: {roots:?}"
11211 );
11212 }
11213
11214 #[test]
11215 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11216 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11220 let junk = tempfile::tempdir().unwrap();
11221
11222 let removed =
11223 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11224
11225 assert_eq!(removed, Removal::Pruned);
11226 assert!(
11227 !admin.exists(),
11228 "the real owner must still prune the orphan"
11229 );
11230 }
11231
11232 #[test]
11233 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11234 let main_dir = tempfile::tempdir().unwrap();
11238 let main_root = main_dir.path().canonicalize().unwrap();
11239 let repo = init_repo(&main_root);
11240 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11241 repo.set_head("refs/heads/trunk").unwrap();
11242 let wt_parent = tempfile::tempdir().unwrap();
11243 let wt_root = wt_parent.path().canonicalize().unwrap();
11244 let orphan = wt_root.join("orphan-wt");
11245 let sibling = wt_root.join("sibling-wt");
11246 add_worktree(&repo, a, &orphan, "orphan");
11247 add_worktree(&repo, a, &sibling, "sibling");
11248 let admin = main_root.join(".git").join("worktrees").join("orphan");
11249 std::fs::remove_dir_all(&orphan).unwrap();
11250
11251 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11254
11255 assert_eq!(removed, Removal::Pruned);
11256 assert!(
11257 !admin.exists(),
11258 "the orphan's admin metadata must be pruned"
11259 );
11260 }
11261
11262 #[test]
11263 fn prune_orphaned_admin_refuses_a_locked_orphan() {
11264 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11268 let main_repo = Repository::open(&main_root).unwrap();
11269 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11270 main_repo
11271 .find_worktree(&name)
11272 .unwrap()
11273 .lock(Some("in use"))
11274 .unwrap();
11275
11276 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11277
11278 assert!(
11279 err.to_string().contains("locked"),
11280 "a locked orphan must be refused, got: {err:#}"
11281 );
11282 assert!(admin.exists(), "a refused prune must leave the admin entry");
11283 }
11284
11285 #[test]
11286 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11287 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11288 assert!(!is_orphaned_worktree(&wt_path));
11290 assert!(!is_orphaned_worktree(main.path()));
11292 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11294 .unwrap();
11295 assert!(is_orphaned_worktree(&wt_path));
11296 }
11297
11298 #[test]
11299 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11300 let tmp = tempfile::tempdir().unwrap();
11301 let missing = tmp.path().join("gone");
11302 assert!(remove_dir_all_retrying(&missing).is_ok());
11303 }
11304
11305 #[test]
11306 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11307 use std::io::Error;
11308 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11309 assert!(
11310 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11311 "errno {errno} is the concurrent-writer race and must be retried"
11312 );
11313 }
11314 for errno in [
11317 nix::libc::EACCES,
11318 nix::libc::EPERM,
11319 nix::libc::EROFS,
11320 nix::libc::ENOTDIR,
11321 ] {
11322 assert!(
11323 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11324 "errno {errno} is permanent and must not be retried"
11325 );
11326 }
11327 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11329 }
11330
11331 #[test]
11332 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11333 let tmp = tempfile::tempdir().unwrap();
11337 let file = tmp.path().join("not-a-directory");
11338 std::fs::write(&file, b"x").unwrap();
11339
11340 let mut attempts = 0;
11341 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11342 attempts += 1;
11343 std::fs::remove_dir_all(&file)
11344 })
11345 .unwrap_err();
11346
11347 assert_eq!(attempts, 1, "a permanent error must not be retried");
11348 assert!(
11349 err.to_string()
11350 .contains("failed to remove worktree directory"),
11351 "unexpected error: {err:#}"
11352 );
11353 assert!(err.source().is_some(), "the io::Error cause is preserved");
11354 assert!(file.exists());
11355 }
11356
11357 #[test]
11358 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11359 let tmp = tempfile::tempdir().unwrap();
11363 let mut attempts = 0;
11364 let backoff = [Duration::ZERO, Duration::ZERO];
11365 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11366 attempts += 1;
11367 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11368 })
11369 .unwrap_err();
11370
11371 assert_eq!(attempts, backoff.len() + 1);
11373 assert!(
11374 err.to_string()
11375 .contains("failed to remove worktree directory"),
11376 "unexpected error: {err:#}"
11377 );
11378 }
11379
11380 #[test]
11381 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11382 let tmp = tempfile::tempdir().unwrap();
11385 let mut attempts = 0;
11386 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11387 attempts += 1;
11388 if attempts < 3 {
11389 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11390 } else {
11391 Ok(())
11392 }
11393 });
11394 assert!(result.is_ok(), "{result:?}");
11395 assert_eq!(attempts, 3);
11396 }
11397
11398 #[test]
11399 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11400 let tmp = tempfile::tempdir().unwrap();
11403 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11404 assert!(!is_orphaned_worktree(tmp.path()));
11405 }
11406
11407 #[test]
11408 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11409 let tmp = tempfile::tempdir().unwrap();
11412 let plain = tmp.path().join("plain");
11413 std::fs::create_dir(&plain).unwrap();
11414
11415 let err = remove_worktree(&plain, &[]).unwrap_err();
11416
11417 assert!(
11418 err.to_string().contains("not a git worktree"),
11419 "unexpected error: {err:#}"
11420 );
11421 assert!(plain.exists(), "a non-worktree path must be left alone");
11422 }
11423
11424 #[test]
11425 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11426 use std::sync::atomic::{AtomicBool, Ordering};
11432 use std::sync::Arc;
11433
11434 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11435 let nested = wt_path.join("target").join("nested");
11441 std::fs::create_dir_all(&nested).unwrap();
11442
11443 let stop = Arc::new(AtomicBool::new(false));
11444 let writer_stop = Arc::clone(&stop);
11445 let writer_dir = nested;
11446 let writer = std::thread::spawn(move || {
11447 let mut n = 0u64;
11448 let deadline = std::time::Instant::now() + Duration::from_millis(400);
11451 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11452 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11457 n += 1;
11458 }
11459 });
11460
11461 let result = remove_worktree(&wt_path, &[]);
11462 stop.store(true, Ordering::Relaxed);
11463 writer.join().unwrap();
11464
11465 assert!(
11466 result.is_ok(),
11467 "removal should retry past the writer: {result:?}"
11468 );
11469 assert!(!wt_path.exists(), "the worktree directory should be gone");
11470 }
11471
11472 #[tokio::test]
11473 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11474 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11475 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11477
11478 let svc = WorktreesService::new();
11479 let report = svc
11480 .handle("close", json!({ "path": wt_path, "remove": true }))
11481 .await
11482 .unwrap();
11483 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11484 assert!(
11485 risks
11486 .iter()
11487 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11488 "expected an untracked risk: {report}"
11489 );
11490 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11492 assert!(wt_path.exists());
11494 }
11495
11496 #[tokio::test]
11497 async fn close_confirmed_removes_a_dirty_worktree() {
11498 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11499 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11500 let svc = WorktreesService::new();
11501 let reply = svc
11503 .handle(
11504 "close",
11505 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11506 )
11507 .await
11508 .unwrap();
11509 assert_eq!(reply, json!({ "removed": true }));
11510 assert!(!wt_path.exists());
11511 }
11512
11513 #[tokio::test]
11514 async fn close_refuses_to_remove_the_main_working_tree() {
11515 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11516 let svc = WorktreesService::new();
11517 let report = svc
11519 .handle("close", json!({ "path": main.path(), "remove": true }))
11520 .await
11521 .unwrap();
11522 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11523 assert_eq!(
11524 report.get("removable").and_then(Value::as_bool),
11525 Some(false)
11526 );
11527 assert!(svc
11530 .handle(
11531 "close",
11532 json!({ "path": main.path(), "remove": true, "confirmed": true }),
11533 )
11534 .await
11535 .is_err());
11536 assert!(main.path().exists());
11537 }
11538
11539 #[tokio::test]
11540 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11541 let main_dir = tempfile::tempdir().unwrap();
11545 let repo = init_repo(main_dir.path());
11546 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11547 repo.set_head("refs/heads/trunk").unwrap();
11548 let wt_parent = tempfile::tempdir().unwrap();
11549 let wt_path = wt_parent.path().join("main-wt");
11550 add_worktree(&repo, a, &wt_path, "main");
11551
11552 let svc = WorktreesService::new();
11553 let reply = svc
11554 .handle(
11555 "close",
11556 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11557 )
11558 .await
11559 .unwrap();
11560 assert_eq!(reply, json!({ "removed": true }));
11561 assert!(!wt_path.exists());
11562 assert!(
11564 repo.find_branch("main", git2::BranchType::Local).is_ok(),
11565 "the default branch must survive worktree removal"
11566 );
11567 }
11568
11569 #[tokio::test]
11570 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11571 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11572 let svc = WorktreesService::new();
11573 svc.handle(
11575 "close",
11576 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11577 )
11578 .await
11579 .unwrap();
11580 let reply = svc
11583 .handle(
11584 "close",
11585 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11586 )
11587 .await
11588 .unwrap();
11589 assert_eq!(reply, json!({ "removed": true }));
11590 }
11591
11592 #[tokio::test]
11593 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11594 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11599 let svc = WorktreesService::new();
11600 svc.handle(
11603 "register",
11604 register_payload("main-w", None, &main_root.display().to_string()),
11605 )
11606 .await
11607 .unwrap();
11608
11609 let before = svc.handle("tree", Value::Null).await.unwrap();
11611 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11612 assert_eq!(
11613 worktrees_before, 2,
11614 "the orphaned row is present before close"
11615 );
11616
11617 let reply = svc
11618 .handle(
11619 "close",
11620 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11621 )
11622 .await
11623 .unwrap();
11624 assert_eq!(reply, json!({ "removed": true }));
11625
11626 assert!(!admin.exists(), "the admin metadata must be pruned");
11628 let after = svc.handle("tree", Value::Null).await.unwrap();
11629 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11630 assert_eq!(worktrees_after, 1, "only the main working tree remains");
11631 }
11632
11633 #[tokio::test]
11634 async fn close_safety_check_detects_detached_head_unreachable_commits() {
11635 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11636 let wt_repo = Repository::open(&wt_path).unwrap();
11639 let parent_oid = wt_repo.head().unwrap().target().unwrap();
11640 let parent = wt_repo.find_commit(parent_oid).unwrap();
11641 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11642 wt_repo.set_head_detached(orphan).unwrap();
11643
11644 let svc = WorktreesService::new();
11645 let report = svc
11646 .handle("close", json!({ "path": wt_path, "remove": true }))
11647 .await
11648 .unwrap();
11649 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11650 assert!(
11651 risks
11652 .iter()
11653 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11654 "expected an unreachable-commits risk: {report}"
11655 );
11656 }
11657
11658 #[tokio::test]
11659 async fn close_self_close_removes_when_the_requester_owns_the_target() {
11660 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11661 let svc = WorktreesService::new();
11662 svc.handle(
11666 "register",
11667 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11668 )
11669 .await
11670 .unwrap();
11671 let reply = svc
11672 .handle(
11673 "close",
11674 json!({
11675 "path": wt_path,
11676 "remove": true,
11677 "confirmed": true,
11678 "requester_key": "w1",
11679 }),
11680 )
11681 .await
11682 .unwrap();
11683 assert_eq!(reply, json!({ "removed": true }));
11684 assert!(!wt_path.exists());
11685 }
11686
11687 #[tokio::test]
11688 async fn close_safety_check_surfaces_the_owning_window() {
11689 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11690 let svc = WorktreesService::new();
11691 svc.handle(
11694 "register",
11695 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11696 )
11697 .await
11698 .unwrap();
11699 let report = svc
11700 .handle("close", json!({ "path": wt_path, "remove": true }))
11701 .await
11702 .unwrap();
11703 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11704 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11705 assert_eq!(
11706 report.get("window_folder_count").and_then(Value::as_u64),
11707 Some(2)
11708 );
11709 }
11710
11711 #[tokio::test]
11712 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11713 let svc = WorktreesService::new();
11714 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11715 .await
11716 .unwrap();
11717 assert_eq!(
11719 svc.handle("heartbeat", json!({ "key": "w1" }))
11720 .await
11721 .unwrap(),
11722 json!({ "known": true })
11723 );
11724 svc.registry.mark_close_pending("w1");
11726 assert_eq!(
11727 svc.handle("heartbeat", json!({ "key": "w1" }))
11728 .await
11729 .unwrap(),
11730 json!({ "known": true, "close": true })
11731 );
11732 assert_eq!(
11733 svc.handle("heartbeat", json!({ "key": "w1" }))
11734 .await
11735 .unwrap(),
11736 json!({ "known": true })
11737 );
11738 }
11739
11740 #[tokio::test]
11743 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11744 let svc = WorktreesService::new();
11745 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11746 .await
11747 .unwrap();
11748 assert_eq!(
11751 svc.handle("heartbeat", json!({ "key": "w1" }))
11752 .await
11753 .unwrap(),
11754 json!({ "known": true })
11755 );
11756 svc.registry.mark_reload_pending("w1");
11758 assert_eq!(
11759 svc.handle("heartbeat", json!({ "key": "w1" }))
11760 .await
11761 .unwrap(),
11762 json!({ "known": true, "reload": true })
11763 );
11764 assert_eq!(
11765 svc.handle("heartbeat", json!({ "key": "w1" }))
11766 .await
11767 .unwrap(),
11768 json!({ "known": true })
11769 );
11770 }
11771
11772 #[tokio::test]
11773 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11774 let svc = WorktreesService::new();
11775 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11776 .await
11777 .unwrap();
11778 svc.registry.mark_close_pending("w1");
11782 svc.registry.mark_reload_pending("w1");
11783 assert_eq!(
11784 svc.handle("heartbeat", json!({ "key": "w1" }))
11785 .await
11786 .unwrap(),
11787 json!({ "known": true, "close": true, "reload": true })
11788 );
11789 assert_eq!(
11790 svc.handle("heartbeat", json!({ "key": "w1" }))
11791 .await
11792 .unwrap(),
11793 json!({ "known": true })
11794 );
11795 }
11796
11797 #[tokio::test]
11798 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11799 let svc = WorktreesService::new();
11800 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11801 .await
11802 .unwrap();
11803 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11804 .await
11805 .unwrap();
11806
11807 let reply = svc
11810 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11811 .await
11812 .unwrap();
11813 assert_eq!(
11814 reply,
11815 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11816 );
11817
11818 assert!(svc.registry.take_reload_pending("w1"));
11821 assert!(svc.registry.take_reload_pending("w2"));
11822 assert!(!svc.registry.take_reload_pending("ghost"));
11823 }
11824
11825 #[tokio::test]
11826 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11827 let svc = WorktreesService::new();
11828 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11829 .await
11830 .unwrap();
11831
11832 assert_eq!(
11835 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11836 .await
11837 .unwrap(),
11838 json!({ "requested": 1, "signalled": 1, "unknown": [] })
11839 );
11840
11841 assert_eq!(
11844 svc.handle("reload", json!({ "target_keys": [] }))
11845 .await
11846 .unwrap(),
11847 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11848 );
11849 assert_eq!(
11850 svc.handle("reload", json!({})).await.unwrap(),
11851 json!({ "requested": 0, "signalled": 0, "unknown": [] })
11852 );
11853 }
11854
11855 #[tokio::test]
11856 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11857 let svc = WorktreesService::new();
11858 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11859 .await
11860 .unwrap();
11861 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11862 .await
11863 .unwrap();
11864
11865 svc.handle("reload", json!({ "target_keys": ["w2"] }))
11868 .await
11869 .unwrap();
11870 assert_eq!(
11871 svc.handle("heartbeat", json!({ "key": "w2" }))
11872 .await
11873 .unwrap(),
11874 json!({ "known": true, "reload": true })
11875 );
11876 assert_eq!(
11878 svc.handle("heartbeat", json!({ "key": "w1" }))
11879 .await
11880 .unwrap(),
11881 json!({ "known": true })
11882 );
11883 }
11884
11885 #[tokio::test]
11886 async fn reload_op_treats_an_unregistered_window_as_unknown() {
11887 let svc = WorktreesService::new();
11888 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11889 .await
11890 .unwrap();
11891 svc.handle("unregister", json!({ "key": "w1" }))
11892 .await
11893 .unwrap();
11894 assert_eq!(
11897 svc.handle("reload", json!({ "target_keys": ["w1"] }))
11898 .await
11899 .unwrap(),
11900 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11901 );
11902 }
11903
11904 #[tokio::test]
11905 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11906 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11907 let svc = Arc::new(WorktreesService::new());
11908 svc.handle(
11910 "register",
11911 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11912 )
11913 .await
11914 .unwrap();
11915
11916 let svc2 = svc.clone();
11919 let path = wt_path.clone();
11920 let close = tokio::spawn(async move {
11921 svc2.handle(
11922 "close",
11923 json!({
11924 "path": path,
11925 "remove": true,
11926 "confirmed": true,
11927 "requester_key": "w1",
11928 }),
11929 )
11930 .await
11931 });
11932
11933 let mut saw_close = false;
11936 for _ in 0..200 {
11937 let hb = svc
11938 .handle("heartbeat", json!({ "key": "w2" }))
11939 .await
11940 .unwrap();
11941 if hb.get("close").and_then(Value::as_bool) == Some(true) {
11942 saw_close = true;
11943 svc.handle("unregister", json!({ "key": "w2" }))
11944 .await
11945 .unwrap();
11946 break;
11947 }
11948 tokio::time::sleep(Duration::from_millis(5)).await;
11949 }
11950 assert!(saw_close, "w2 should have been told to close");
11951
11952 let reply = close.await.unwrap().unwrap();
11954 assert_eq!(reply, json!({ "removed": true }));
11955 assert!(!wt_path.exists());
11956 }
11957
11958 #[tokio::test]
11959 async fn await_windows_closed_times_out_when_a_window_never_closes() {
11960 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11961 let svc = WorktreesService::new();
11962 svc.handle(
11963 "register",
11964 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11965 )
11966 .await
11967 .unwrap();
11968 let err = await_windows_closed(
11971 &svc.registry,
11972 &wt_path,
11973 Some("w1"),
11974 Duration::from_millis(150),
11975 Duration::from_millis(25),
11976 )
11977 .await
11978 .unwrap_err();
11979 assert!(
11980 err.to_string().contains("w2"),
11981 "error names the window: {err}"
11982 );
11983 await_windows_closed(
11985 &svc.registry,
11986 &wt_path,
11987 Some("w2"),
11988 Duration::from_millis(150),
11989 Duration::from_millis(25),
11990 )
11991 .await
11992 .unwrap();
11993 }
11994
11995 #[tokio::test]
11996 async fn close_window_without_remove_replies_closed_and_never_deletes() {
11997 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11998 let svc = WorktreesService::new();
11999 let reply = svc
12001 .handle("close", json!({ "path": main.path(), "remove": false }))
12002 .await
12003 .unwrap();
12004 assert_eq!(reply, json!({ "closed": true }));
12005 assert!(main.path().exists());
12006 }
12007
12008 #[tokio::test]
12009 async fn close_safety_check_flags_modified_tracked_files() {
12010 let main_dir = tempfile::tempdir().unwrap();
12014 let repo = init_repo(main_dir.path());
12015 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12016 repo.set_head("refs/heads/trunk").unwrap();
12017 let wt_parent = tempfile::tempdir().unwrap();
12018 let wt_path = wt_parent.path().join("feature-wt");
12019 add_worktree(&repo, a, &wt_path, "feature");
12020 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12021
12022 let svc = WorktreesService::new();
12023 let report = svc
12024 .handle("close", json!({ "path": wt_path, "remove": true }))
12025 .await
12026 .unwrap();
12027 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12028 assert!(
12029 risks
12030 .iter()
12031 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12032 "expected a dirty risk: {report}"
12033 );
12034 }
12035
12036 #[tokio::test]
12037 async fn close_safety_check_flags_an_in_progress_operation() {
12038 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12041 let wt_repo = Repository::open(&wt_path).unwrap();
12042 let head = wt_repo.head().unwrap().target().unwrap();
12043 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12044 assert_ne!(wt_repo.state(), RepositoryState::Clean);
12045
12046 let svc = WorktreesService::new();
12047 let report = svc
12048 .handle("close", json!({ "path": wt_path, "remove": true }))
12049 .await
12050 .unwrap();
12051 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12052 assert!(
12053 risks
12054 .iter()
12055 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12056 "expected an in-progress risk: {report}"
12057 );
12058 }
12059
12060 #[tokio::test]
12061 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12062 let main_dir = tempfile::tempdir().unwrap();
12066 let repo = init_repo(main_dir.path());
12067 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12068 repo.set_head("refs/heads/trunk").unwrap();
12069 let a_commit = repo.find_commit(a).unwrap();
12070 repo.branch("feature", &a_commit, false).unwrap();
12071 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12072 .unwrap();
12073 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12075 drop(a_commit);
12076 let mut cfg = repo.config().unwrap();
12077 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12078 .unwrap();
12079 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12080 .unwrap();
12081 cfg.set_str("branch.feature.remote", "origin").unwrap();
12082 cfg.set_str("branch.feature.merge", "refs/heads/feature")
12083 .unwrap();
12084 let wt_parent = tempfile::tempdir().unwrap();
12087 let wt_path = wt_parent.path().join("feature-wt");
12088 let reference = repo.find_reference("refs/heads/feature").unwrap();
12089 let mut opts = git2::WorktreeAddOptions::new();
12090 opts.reference(Some(&reference));
12091 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12092
12093 let svc = WorktreesService::new();
12094 let report = svc
12095 .handle("close", json!({ "path": wt_path, "remove": true }))
12096 .await
12097 .unwrap();
12098 let info = report.get("info").and_then(Value::as_array).unwrap();
12101 assert!(
12102 info.iter()
12103 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12104 "expected an unpushed info note: {report}"
12105 );
12106 assert!(
12107 report
12108 .get("risks")
12109 .and_then(Value::as_array)
12110 .unwrap()
12111 .is_empty(),
12112 "unpushed commits alone must not block: {report}"
12113 );
12114 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12115 }
12116
12117 #[tokio::test]
12118 async fn close_safety_check_ignores_gitignored_files() {
12119 let main_dir = tempfile::tempdir().unwrap();
12123 let repo = init_repo(main_dir.path());
12124 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12125 repo.set_head("refs/heads/trunk").unwrap();
12126 let wt_parent = tempfile::tempdir().unwrap();
12127 let wt_path = wt_parent.path().join("feature-wt");
12128 add_worktree(&repo, a, &wt_path, "feature");
12129 std::fs::create_dir(wt_path.join("build")).unwrap();
12130 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12131
12132 let svc = WorktreesService::new();
12133 let report = svc
12134 .handle("close", json!({ "path": wt_path, "remove": true }))
12135 .await
12136 .unwrap();
12137 assert!(
12138 report
12139 .get("risks")
12140 .and_then(Value::as_array)
12141 .unwrap()
12142 .is_empty(),
12143 "a gitignored file must not create a risk: {report}"
12144 );
12145 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12146 }
12147
12148 #[tokio::test]
12149 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12150 let svc = WorktreesService::new();
12153 let report = svc
12154 .handle(
12155 "close",
12156 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12157 )
12158 .await
12159 .unwrap();
12160 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12161 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12162 assert!(report
12163 .get("risks")
12164 .and_then(Value::as_array)
12165 .unwrap()
12166 .is_empty());
12167 let info = report.get("info").and_then(Value::as_array).unwrap();
12168 assert!(info
12169 .iter()
12170 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12171 }
12172
12173 #[tokio::test]
12174 async fn close_phase1_errors_on_a_non_git_worktree_path() {
12175 let dir = tempfile::tempdir().unwrap();
12180 let svc = WorktreesService::new();
12181 let result = svc
12182 .handle("close", json!({ "path": dir.path(), "remove": true }))
12183 .await;
12184 assert!(
12185 result.is_err(),
12186 "a non-git-worktree target must error the safety check, got: {result:?}"
12187 );
12188 }
12189
12190 #[tokio::test]
12191 async fn close_refuses_a_locked_worktree() {
12192 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12195 let main_repo = Repository::open(main.path()).unwrap();
12196 main_repo
12197 .find_worktree("feature")
12198 .unwrap()
12199 .lock(Some("under test"))
12200 .unwrap();
12201
12202 let svc = WorktreesService::new();
12203 let err = svc
12204 .handle(
12205 "close",
12206 json!({ "path": wt_path, "remove": true, "confirmed": true }),
12207 )
12208 .await
12209 .unwrap_err();
12210 assert!(
12211 err.to_string().contains("locked"),
12212 "expected a locked error: {err}"
12213 );
12214 assert!(wt_path.exists(), "a locked worktree must not be removed");
12215 }
12216
12217 #[tokio::test]
12218 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12219 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12223 let wt_repo = Repository::open(&wt_path).unwrap();
12224 let tip = wt_repo.head().unwrap().target().unwrap();
12227 wt_repo.set_head_detached(tip).unwrap();
12228 assert!(wt_repo.head_detached().unwrap());
12229
12230 let svc = WorktreesService::new();
12231 let report = svc
12232 .handle("close", json!({ "path": wt_path, "remove": true }))
12233 .await
12234 .unwrap();
12235 let risks = report.get("risks").and_then(Value::as_array).unwrap();
12236 assert!(
12237 !risks
12238 .iter()
12239 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12240 "a detached HEAD reachable from a branch must not be flagged: {report}"
12241 );
12242 }
12243
12244 #[test]
12245 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12246 let (main, _wtp, wt_path) = repo_with_linked_worktree();
12247 let main_repo = Repository::open(main.path()).unwrap();
12248 assert_eq!(
12250 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12251 "feature"
12252 );
12253 let err =
12256 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12257 assert!(
12258 err.to_string().contains("not registered"),
12259 "expected a not-registered error: {err}"
12260 );
12261 }
12262
12263 #[test]
12264 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12265 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12268 let repo = Repository::open(&wt_path).unwrap();
12269 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12270 assert!(
12273 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12274 "a corrupt index should make statuses() fail"
12275 );
12276 assert_eq!(count_dirty_untracked(&repo), (0, 0));
12277 }
12278}