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::worktree_rebase::{self, Selection};
46use crate::github_rate_limit::{
47 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
48};
49use crate::pr_status::{
50 EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
51};
52use async_trait::async_trait;
53use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
54use serde::{Deserialize, Serialize};
55use serde_json::{json, Value};
56use tokio::sync::watch;
57use tokio::sync::Mutex as AsyncMutex;
58use tokio::task::JoinHandle;
59use tokio_util::sync::CancellationToken;
60
61use crate::daemon::service::{
62 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
63};
64use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
65
66pub const SERVICE_NAME: &str = "worktrees";
68
69const SUBMENU_TITLE: &str = "Worktrees";
71
72const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
75
76const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
79
80const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
92
93fn menu_refresh_interval() -> Duration {
96 crate::daemon::server::duration_secs_from_env(
97 ENV_MENU_REFRESH_INTERVAL,
98 DEFAULT_MENU_REFRESH_INTERVAL,
99 )
100}
101
102const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
106
107const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
115
116const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
124
125const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
131
132const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
138
139const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
145
146const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
150
151const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
158
159fn pr_poll_interval() -> Duration {
162 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
163}
164
165fn pr_debounce_interval() -> Duration {
168 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
169}
170
171const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
175
176const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
181
182const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
186
187const OPEN_PR_LIST_LIMIT: &str = "100";
190
191fn open_pr_ttl() -> Duration {
194 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
195}
196
197const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
201
202const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
209
210fn rate_limit_poll_interval() -> Duration {
213 crate::daemon::server::duration_secs_from_env(
214 ENV_RATE_LIMIT_POLL_INTERVAL,
215 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
216 )
217}
218
219struct RefreshTask {
221 token: CancellationToken,
223 handle: JoinHandle<()>,
225}
226
227fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
244 grew || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
247}
248
249fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
264 next.iter().any(|w| !prev.contains(w))
265}
266
267fn next_pr_poll_delay(
285 current: Duration,
286 base: Duration,
287 pending: bool,
288 since_moved: Option<Duration>,
289) -> Duration {
290 if !pending {
291 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
292 }
293 match since_moved {
294 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
296 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
299 }
300}
301
302fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
314 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
315 delay.max(BUDGET_THROTTLE_INTERVAL)
316 } else {
317 delay
318 }
319}
320
321fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
329 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
330 [
334 (prev.and_then(|p| p.graphql), next.graphql),
335 (prev.and_then(|p| p.core), next.core),
336 (prev.and_then(|p| p.search), next.search),
337 ]
338 .into_iter()
339 .any(|(before, after)| over(after) && !over(before))
340}
341
342struct PollerTask {
344 token: CancellationToken,
346 handle: JoinHandle<()>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
367struct PrWatch {
368 target: PrTarget,
370 upstream_sha: Option<String>,
372}
373
374fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
383 let mut out = Vec::new();
384 for repo in snapshot
385 .get("repos")
386 .and_then(Value::as_array)
387 .into_iter()
388 .flatten()
389 {
390 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
395 continue;
396 }
397 let Some(github) = repo.get("github") else {
398 continue;
399 };
400 let (Some(owner), Some(name)) = (
401 github.get("owner").and_then(Value::as_str),
402 github.get("name").and_then(Value::as_str),
403 ) else {
404 continue;
405 };
406 for wt in repo
407 .get("worktrees")
408 .and_then(Value::as_array)
409 .into_iter()
410 .flatten()
411 {
412 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
413 out.push(PrWatch {
414 upstream_sha: wt
415 .get("upstream_sha")
416 .and_then(Value::as_str)
417 .map(str::to_string),
418 target: PrTarget {
419 owner: owner.to_string(),
420 name: name.to_string(),
421 branch: branch.to_string(),
422 },
423 });
424 }
425 }
426 }
427 out.sort();
428 out.dedup();
429 out
430}
431
432#[cfg(test)]
435fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
436 pr_watch_from_snapshot(snapshot)
437 .into_iter()
438 .map(|w| w.target)
439 .collect()
440}
441
442pub struct WorktreesService {
444 registry: Arc<WorktreesRegistry>,
447 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
453 refresh: Mutex<Option<RefreshTask>>,
455 pr_cache: Arc<PrStatusCache>,
461 poller: Mutex<Option<PollerTask>>,
464 rate_limit_cache: Arc<RateLimitCache>,
472 rate_limit_poller: Mutex<Option<PollerTask>>,
475 tree_cache: Arc<TreeSnapshotCache>,
481 prune_lock: tokio::sync::Mutex<()>,
498 polling_prefs_path: Mutex<Option<PathBuf>>,
506 pr_cache_path: Mutex<Option<PathBuf>>,
513 pr_warm_start: Mutex<Option<PrWarmStart>>,
518 open_pr_cache: Arc<OpenPrCache>,
523 reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
539 rebase_lock: tokio::sync::Mutex<()>,
551}
552
553impl WorktreesService {
554 #[must_use]
559 pub fn new() -> Self {
560 let registry = Arc::new(WorktreesRegistry::new());
561 let pr_cache = Arc::new(PrStatusCache::new());
562 Self {
563 registry: registry.clone(),
564 menu_cache: Arc::new(Mutex::new(None)),
565 refresh: Mutex::new(None),
566 pr_cache: pr_cache.clone(),
567 poller: Mutex::new(None),
568 rate_limit_cache: Arc::new(RateLimitCache::new()),
569 rate_limit_poller: Mutex::new(None),
570 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
571 prune_lock: tokio::sync::Mutex::new(()),
572 polling_prefs_path: Mutex::new(None),
573 pr_cache_path: Mutex::new(None),
574 pr_warm_start: Mutex::new(None),
575 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
576 reposition_undo: Mutex::new(Vec::new()),
577 rebase_lock: tokio::sync::Mutex::new(()),
578 }
579 }
580
581 pub fn load_polling_prefs(&self, path: PathBuf) {
590 match std::fs::read(&path) {
591 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
592 Ok(prefs) => self
593 .registry
594 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
595 Err(err) => tracing::warn!(
596 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
597 path.display()
598 ),
599 },
600 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
601 Err(err) => tracing::warn!(
602 "could not read worktrees polling prefs at {}: {err:#}",
603 path.display()
604 ),
605 }
606 *self
607 .polling_prefs_path
608 .lock()
609 .unwrap_or_else(PoisonError::into_inner) = Some(path);
610 }
611
612 fn persist_polling_prefs(&self) {
619 let Some(path) = self
620 .polling_prefs_path
621 .lock()
622 .unwrap_or_else(PoisonError::into_inner)
623 .clone()
624 else {
625 return;
626 };
627 let prefs = PollingPrefs {
628 enabled: self
629 .registry
630 .polling_snapshot()
631 .into_iter()
632 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
633 .collect(),
634 };
635 if let Err(err) = write_polling_prefs(&path, &prefs) {
636 tracing::warn!(
637 "could not persist worktrees polling prefs to {}: {err:#}",
638 path.display()
639 );
640 }
641 }
642
643 pub fn load_pr_cache(&self, path: PathBuf) {
656 match std::fs::read(&path) {
657 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
658 Ok(prefs) => {
659 self.pr_cache.seed(
660 prefs
661 .entries
662 .into_iter()
663 .map(|e| (e.target, e.resolution.into_resolution())),
664 );
665 if let Some(polled_at) = prefs.polled_at {
670 let watched = prefs
671 .watched
672 .into_iter()
673 .map(|w| PrWatch {
674 target: w.target,
675 upstream_sha: w.upstream_sha,
676 })
677 .collect();
678 *self
679 .pr_warm_start
680 .lock()
681 .unwrap_or_else(PoisonError::into_inner) =
682 Some(PrWarmStart { watched, polled_at });
683 }
684 }
685 Err(err) => {
689 let at = path.display();
690 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
691 }
692 },
693 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
694 Err(err) => {
695 let at = path.display();
696 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
697 }
698 }
699 *self
700 .pr_cache_path
701 .lock()
702 .unwrap_or_else(PoisonError::into_inner) = Some(path);
703 }
704
705 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
712 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
716 .await
717 }
718
719 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
722 let key = format!("{owner}/{name}");
723 if let Some(prs) = self.open_pr_cache.fresh(&key) {
724 return Ok(prs);
725 }
726 let slug = key.clone();
727 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
728 .await
729 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
730 self.open_pr_cache.store(key, prs.clone());
731 Ok(prs)
732 }
733
734 #[must_use]
738 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
739 self.rate_limit_cache.clone()
740 }
741
742 pub fn start_menu_refresh(&self) {
750 if tokio::runtime::Handle::try_current().is_err() {
751 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
752 return;
753 }
754 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
755 if guard.is_some() {
756 return;
757 }
758 let token = CancellationToken::new();
759 let loop_token = token.clone();
760 let registry = self.registry.clone();
761 let cache = self.menu_cache.clone();
762 let rate_limit_cache = self.rate_limit_cache.clone();
763 let interval = menu_refresh_interval();
766 let handle = tokio::spawn(async move {
767 loop {
768 let entries = registry.list();
772 let rate_limit = rate_limit_cache.get();
775 if let Ok(items) = tokio::task::spawn_blocking(move || {
776 menu_items_for(&entries, rate_limit.as_ref())
777 })
778 .await
779 {
780 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
781 }
782 tokio::select! {
783 () = loop_token.cancelled() => break,
784 () = tokio::time::sleep(interval) => {}
785 }
786 }
787 });
788 *guard = Some(RefreshTask { token, handle });
789 }
790
791 pub fn start_pr_poller(&self) {
822 self.start_pr_poller_with(
825 pr_poll_interval(),
826 pr_debounce_interval(),
827 crate::pr_status::resolve_gh_binary(),
828 );
829 }
830
831 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
842 if tokio::runtime::Handle::try_current().is_err() {
843 tracing::debug!("no tokio runtime; worktrees PR poller not started");
844 return;
845 }
846 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
847 if guard.is_some() {
848 return;
849 }
850 let token = CancellationToken::new();
851 let loop_token = token.clone();
852 let registry = self.registry.clone();
853 let tree_cache = self.tree_cache.clone();
854 let pr_cache = self.pr_cache.clone();
855 let rate_limit_cache = self.rate_limit_cache.clone();
859 let pr_cache_path = self
860 .pr_cache_path
861 .lock()
862 .unwrap_or_else(PoisonError::into_inner)
863 .clone();
864 let warm_start = self
865 .pr_warm_start
866 .lock()
867 .unwrap_or_else(PoisonError::into_inner)
868 .take();
869 let mut changes = self.registry.subscribe_changes();
872 let handle = tokio::spawn(async move {
873 let mut backoff = base;
884 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
891 match warm_start {
892 Some(ws) => {
893 let elapsed = (Utc::now() - ws.polled_at)
894 .to_std()
895 .unwrap_or(Duration::ZERO);
896 (Some(ws.watched), Instant::now().checked_sub(elapsed))
897 }
898 None => (None, None),
899 };
900 let mut moved_at: Option<Instant> = None;
903 'poll: loop {
904 tokio::select! {
907 () = loop_token.cancelled() => break,
908 () = tokio::time::sleep(base) => {}
909 result = changes.changed() => {
912 if result.is_err() {
919 break;
920 }
921 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
929 loop {
930 tokio::select! {
931 () = loop_token.cancelled() => break 'poll,
932 () = tokio::time::sleep(debounce) => break,
933 r = changes.changed() => {
934 if r.is_err() {
935 break 'poll;
936 }
937 if Instant::now() >= overall_deadline {
938 break;
939 }
940 }
941 }
942 }
943 }
944 }
945 let snapshot = tree_cache.snapshot().await;
948 let watch = pr_watch_from_snapshot(&snapshot);
949 if watch.is_empty() {
950 backoff = base;
957 last_poll = None;
958 moved_at = None;
959 continue;
960 }
961 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
965 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
969 pr_cache.retain_targets(&keep);
970 let rate_limit = rate_limit_cache.get();
976 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
977 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
978 let trigger = grew && !over_budget;
979 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
980 if !grew {
985 watched = Some(watch);
986 }
987 continue;
988 }
989 if grew {
990 backoff = base;
993 moved_at = Some(Instant::now());
994 }
995 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
996 let bin = gh_bin.clone();
1001 let resolved = tokio::task::spawn_blocking(move || {
1002 crate::pr_status::resolve_with_budget(&bin, &targets)
1003 })
1004 .await
1005 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1006 let (pending, resolved_ok) = match resolved {
1013 Ok((resolutions, budget)) => {
1014 if let Some(b) = budget {
1020 tracing::debug!(
1021 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1022 b.cost,
1023 b.used,
1024 b.limit,
1025 b.remaining
1026 );
1027 rate_limit_cache.observe_graphql(RateLimitResource::new(
1028 b.used,
1029 b.limit,
1030 b.remaining,
1031 b.reset,
1032 ));
1033 }
1034 if pr_cache.replace(resolutions) {
1037 registry.bump();
1038 }
1039 (pr_cache.any_pending(), true)
1040 }
1041 Err(err) => {
1042 tracing::debug!("PR badge poll failed: {err:#}");
1043 (false, false)
1044 }
1045 };
1046 last_poll = Some(Instant::now());
1047 watched = Some(watch);
1050 let since_moved = moved_at.map(|at| at.elapsed());
1051 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1052 if resolved_ok {
1058 if let Some(path) = &pr_cache_path {
1059 persist_pr_cache(
1060 path,
1061 &pr_cache,
1062 watched.as_deref().unwrap_or(&[]),
1063 Utc::now(),
1064 );
1065 }
1066 }
1067 }
1068 });
1069 *guard = Some(PollerTask { token, handle });
1070 }
1071
1072 pub fn start_rate_limit_poller(&self) {
1090 self.start_rate_limit_poller_with(
1093 rate_limit_poll_interval(),
1094 crate::pr_status::resolve_gh_binary(),
1095 );
1096 }
1097
1098 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1103 if tokio::runtime::Handle::try_current().is_err() {
1104 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1105 return;
1106 }
1107 let mut guard = self
1108 .rate_limit_poller
1109 .lock()
1110 .unwrap_or_else(PoisonError::into_inner);
1111 if guard.is_some() {
1112 return;
1113 }
1114 let token = CancellationToken::new();
1115 let loop_token = token.clone();
1116 let cache = self.rate_limit_cache.clone();
1117 let registry = self.registry.clone();
1118 let handle = tokio::spawn(async move {
1119 let mut prev: Option<RateLimitSnapshot> = None;
1122 loop {
1123 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1131 let bin = gh_bin.clone();
1137 let resolved =
1138 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1139 .await
1140 .unwrap_or_else(|err| {
1141 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1142 });
1143 match resolved {
1144 Ok(snap) => {
1145 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1146 let summary = snap.summary_line();
1152 tracing::warn!(
1153 "GitHub API rate limit high: {summary} (querying \
1154 /rate_limit is free; the daemon's gh usage is not)"
1155 );
1156 }
1157 cache.replace(snap);
1162 prev = Some(snap);
1163 }
1164 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1169 }
1170 }
1171 tokio::select! {
1172 () = loop_token.cancelled() => break,
1173 () = tokio::time::sleep(interval) => {}
1174 }
1175 }
1176 });
1177 *guard = Some(PollerTask { token, handle });
1178 }
1179
1180 async fn close(&self, req: CloseRequest) -> Result<Value> {
1195 let entries = self.registry.list();
1199 let scan_path = req.path.clone();
1200 let open_windows =
1201 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1202 .await
1203 .unwrap_or_default();
1204 let open = !open_windows.is_empty();
1205 let window_key = open_windows.first().map(|(k, _)| k.clone());
1206 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1207
1208 if req.remove && !req.confirmed {
1212 let path = req.path.clone();
1213 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1214 .await
1215 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1216 .and_then(|inner| inner)
1217 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1218 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1224 return Ok(serde_json::to_value(SafetyReport {
1225 removable: git.removable,
1226 is_main: git.is_main,
1227 open,
1228 window_key,
1229 window_folder_count,
1230 risks: git.risks,
1231 info: git.info,
1232 })
1233 .unwrap_or_else(|_| json!({})));
1234 }
1235
1236 let others: Vec<String> = open_windows
1243 .iter()
1244 .map(|(k, _)| k.clone())
1245 .filter(|k| req.requester_key.as_deref() != Some(k))
1246 .collect();
1247 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1252 log_executing(
1253 &req.path,
1254 req.requester_key.as_deref(),
1255 req.remove,
1256 self_close,
1257 others.len(),
1258 );
1259 for key in &others {
1260 self.registry.mark_close_pending(key);
1261 }
1262 if !others.is_empty() {
1263 if let Err(err) = await_windows_closed(
1264 &self.registry,
1265 &req.path,
1266 req.requester_key.as_deref(),
1267 CLOSE_WAIT_TIMEOUT,
1268 CLOSE_WAIT_POLL,
1269 )
1270 .await
1271 {
1272 log_close_abort(&req.path, &err);
1273 return Err(err);
1274 }
1275 }
1276
1277 if req.remove {
1278 let path = req.path.clone();
1279 let entries = self.registry.list();
1283 let _guard = self.prune_lock.lock().await;
1289 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1290 .await
1291 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1292 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1293 log_and_map_removal(&req.path, removed)
1296 } else {
1297 log_window_closed(&req.path);
1300 Ok(json!({ "closed": true }))
1301 }
1302 }
1303
1304 fn reload(&self, req: ReloadRequest) -> Value {
1320 let live: HashSet<String> = self
1321 .registry
1322 .list()
1323 .into_iter()
1324 .map(|entry| entry.key)
1325 .collect();
1326
1327 let mut seen = HashSet::new();
1328 let mut signalled = 0usize;
1329 let mut unknown = Vec::new();
1330 for key in &req.target_keys {
1331 if !seen.insert(key.as_str()) {
1333 continue;
1334 }
1335 if live.contains(key) {
1336 self.registry.mark_reload_pending(key);
1337 signalled += 1;
1338 } else {
1339 unknown.push(key.clone());
1340 }
1341 }
1342
1343 log_reload(seen.len(), signalled, &unknown);
1344 json!({
1345 "requested": seen.len(),
1346 "signalled": signalled,
1347 "unknown": unknown,
1348 })
1349 }
1350
1351 async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1367 self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1372 .await
1373 }
1374
1375 async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1378 let report_only = req.check || !req.confirmed;
1381
1382 let eval_bin = bin.clone();
1383 let eval_paths = req.paths.clone();
1384 let (eligible, skipped) =
1385 tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1386 .await
1387 .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1388 .and_then(|inner| inner)?;
1389
1390 if report_only {
1391 log_merge_check(&req, eligible.len(), skipped.len());
1393 let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1394 return Ok(
1395 serde_json::to_value(EligibilityReport { eligible, skipped })
1396 .unwrap_or_else(|_| json!({})),
1397 );
1398 }
1399
1400 let enqueue_bin = bin.clone();
1402 let (queued, failed) =
1403 tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1404 .await
1405 .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1406 log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1407 Ok(serde_json::to_value(EnqueueResult {
1408 queued,
1409 skipped,
1410 failed,
1411 })
1412 .unwrap_or_else(|_| json!({})))
1413 }
1414
1415 async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1441 self.rebase_with(req, crate::git::resolve_git_binary())
1445 .await
1446 }
1447
1448 async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1451 if req.paths.is_empty() {
1452 bail!("`rebase` requires at least one path");
1453 }
1454 let report_only = req.check || !req.confirmed;
1457 let opts = req.options(git_bin);
1458 let selection = Selection::Paths(req.paths.clone());
1459
1460 if report_only {
1461 let plan = plan_rebase(&selection, &opts).await?;
1464 log_rebase_check(&req, &plan);
1466 return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1467 }
1468
1469 let _guard = self.rebase_lock.lock().await;
1482 let plan = plan_rebase(&selection, &opts).await?;
1483 let pending: Vec<PathBuf> = plan
1487 .worktrees
1488 .iter()
1489 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1490 .map(|w| canonical(&w.path))
1491 .collect();
1492 self.registry.mark_rebasing(&pending);
1493 let fetches = plan.fetches.clone();
1494 let exec_opts = opts.clone();
1495 let outcomes =
1496 tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1497 self.registry.clear_rebasing(&pending);
1500 let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1501
1502 log_rebase_execute(&req, &outcomes);
1503 Ok(rebase_reply(&fetches, &outcomes))
1504 }
1505
1506 async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1524 self.reposition_with(req, geometry::ax::AxBackend::new)
1525 .await
1526 }
1527
1528 async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1537 where
1538 B: geometry::WindowBackend,
1539 F: FnOnce() -> B + Send + 'static,
1540 {
1541 if req.reference_key.trim().is_empty() {
1542 bail!("`reposition` requires a non-empty `reference_key`");
1543 }
1544 let entries = self.registry.list();
1547 let reference = registered_window(&entries, &req.reference_key);
1548 if !reference.live {
1549 bail!(
1552 "no open window with key {} (it may have closed)",
1553 req.reference_key
1554 );
1555 }
1556 let targets: Vec<geometry::RegisteredWindow> = req
1559 .target_keys
1560 .iter()
1561 .map(|key| registered_window(&entries, key))
1562 .collect();
1563
1564 let check = req.check;
1565 let mut report = tokio::task::spawn_blocking(move || {
1566 let backend = make_backend();
1569 geometry::reposition(&backend, &reference, &targets, check)
1570 })
1571 .await
1572 .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1573
1574 let undo = std::mem::take(&mut report.undo);
1579 let undoable = !check && !undo.is_empty();
1580 if undoable {
1581 *self
1582 .reposition_undo
1583 .lock()
1584 .unwrap_or_else(PoisonError::into_inner) = undo;
1585 }
1586 log_reposition(&req, &report);
1587 Ok(reposition_reply(&report, undoable))
1588 }
1589
1590 async fn reposition_undo(&self) -> Result<Value> {
1598 self.reposition_undo_with(geometry::ax::AxBackend::new)
1599 .await
1600 }
1601
1602 async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1606 where
1607 B: geometry::WindowBackend,
1608 F: FnOnce() -> B + Send + 'static,
1609 {
1610 let stored = std::mem::take(
1611 &mut *self
1612 .reposition_undo
1613 .lock()
1614 .unwrap_or_else(PoisonError::into_inner),
1615 );
1616 if stored.is_empty() {
1617 return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1618 }
1619 let entries = self.registry.list();
1620 let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1621 .into_iter()
1622 .map(|(key, frame)| (registered_window(&entries, &key), frame))
1623 .collect();
1624
1625 let report = tokio::task::spawn_blocking(move || {
1626 let backend = make_backend();
1627 geometry::restore(&backend, &restore)
1628 })
1629 .await
1630 .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1631 log_reposition_undo(&report);
1632 Ok(reposition_reply(&report, false))
1633 }
1634}
1635
1636impl Default for WorktreesService {
1637 fn default() -> Self {
1638 Self::new()
1639 }
1640}
1641
1642#[async_trait]
1643impl DaemonService for WorktreesService {
1644 fn name(&self) -> &'static str {
1645 SERVICE_NAME
1646 }
1647
1648 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1649 match op {
1650 "register" => {
1651 let req: RegisterRequest =
1652 serde_json::from_value(payload).context("invalid `register` payload")?;
1653 if req.key.trim().is_empty() {
1654 bail!("`register` requires a non-empty `key`");
1655 }
1656 self.registry.register(req);
1657 Ok(json!({ "ok": true }))
1658 }
1659 "heartbeat" => {
1660 let key = require_str(&payload, "key", "heartbeat")?;
1661 let known = self.registry.heartbeat(key);
1662 let mut reply = json!({ "known": known });
1667 if self.registry.take_close_pending(key) {
1668 reply["close"] = Value::Bool(true);
1669 }
1670 if self.registry.take_reload_pending(key) {
1677 reply["reload"] = Value::Bool(true);
1678 }
1679 Ok(reply)
1680 }
1681 "unregister" => {
1682 let key = require_str(&payload, "key", "unregister")?;
1683 Ok(json!({ "removed": self.registry.unregister(key) }))
1684 }
1685 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1686 "tree" => {
1687 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1695 }
1696 "ahead-behind" => {
1697 let paths = payload
1704 .get("paths")
1705 .and_then(Value::as_array)
1706 .map(|arr| {
1707 arr.iter()
1708 .filter_map(Value::as_str)
1709 .map(PathBuf::from)
1710 .collect::<Vec<_>>()
1711 })
1712 .unwrap_or_default();
1713 Ok(json!({ "results": ahead_behind_results(paths).await }))
1714 }
1715 "set-show-closed" => {
1716 let show_closed = payload
1721 .get("show_closed")
1722 .and_then(Value::as_bool)
1723 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1724 self.registry.set_show_closed(show_closed);
1725 Ok(json!({ "ok": true }))
1726 }
1727 "set-polling" => {
1728 let owner = require_str(&payload, "owner", "set-polling")?;
1737 let name = require_str(&payload, "name", "set-polling")?;
1738 let enabled = payload
1739 .get("enabled")
1740 .and_then(Value::as_bool)
1741 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1742 if owner.trim().is_empty() || name.trim().is_empty() {
1743 bail!("`set-polling` requires a non-empty `owner` and `name`");
1744 }
1745 if self.registry.set_polling(owner, name, enabled) {
1746 self.persist_polling_prefs();
1747 }
1748 Ok(json!({ "ok": true }))
1749 }
1750 "open-prs" => {
1751 let owner = require_str(&payload, "owner", "open-prs")?;
1759 let name = require_str(&payload, "name", "open-prs")?;
1760 if owner.trim().is_empty() || name.trim().is_empty() {
1761 bail!("`open-prs` requires a non-empty `owner` and `name`");
1762 }
1763 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1764 }
1765 "open" => {
1766 let path = require_str(&payload, "path", "open")?;
1775 focus_window(Path::new(path))?;
1776 Ok(json!({ "ok": true }))
1777 }
1778 "close" => {
1779 let req: CloseRequest =
1785 serde_json::from_value(payload).context("invalid `close` payload")?;
1786 self.close(req).await
1787 }
1788 "reload" => {
1789 let req: ReloadRequest =
1797 serde_json::from_value(payload).context("invalid `reload` payload")?;
1798 Ok(self.reload(req))
1799 }
1800 "merge-queue" => {
1801 let req: MergeQueueRequest =
1808 serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1809 self.merge_queue(req).await
1810 }
1811 "rebase" => {
1812 let req: RebaseRequest =
1821 serde_json::from_value(payload).context("invalid `rebase` payload")?;
1822 self.rebase(req).await
1823 }
1824 "reposition" => {
1825 let req: RepositionRequest =
1833 serde_json::from_value(payload).context("invalid `reposition` payload")?;
1834 self.reposition(req).await
1835 }
1836 "reposition-undo" => {
1837 self.reposition_undo().await
1841 }
1842 other => bail!("unknown worktrees op: {other}"),
1843 }
1844 }
1845
1846 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1847 if op != "subscribe" {
1850 return None;
1851 }
1852 Some(Box::new(WorktreesStream {
1853 cache: self.tree_cache.clone(),
1856 changes: self.registry.subscribe_changes(),
1859 }))
1860 }
1861
1862 fn menu(&self) -> MenuSnapshot {
1863 let cached = self
1868 .menu_cache
1869 .lock()
1870 .unwrap_or_else(PoisonError::into_inner)
1871 .clone();
1872 let items = cached.unwrap_or_else(|| {
1873 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1874 });
1875 MenuSnapshot {
1876 title: SUBMENU_TITLE.to_string(),
1877 items,
1878 }
1879 }
1880
1881 async fn menu_action(&self, action_id: &str) -> Result<()> {
1882 if let Some(key) = action_id.strip_prefix("focus:") {
1883 let folder = self
1886 .registry
1887 .first_folder(key)
1888 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
1889 focus_window(&folder)?;
1890 return Ok(());
1891 }
1892 bail!("unknown worktrees menu action: {action_id}")
1893 }
1894
1895 async fn status(&self) -> ServiceStatus {
1896 let entries = self.registry.list();
1897 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
1898 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
1899 let windows = enriched_windows(entries).await;
1900 ServiceStatus {
1901 name: SERVICE_NAME.to_string(),
1902 healthy: true,
1903 summary,
1904 detail: json!({ "windows": windows }),
1905 }
1906 }
1907
1908 async fn shutdown(&self) {
1909 let task = self
1913 .refresh
1914 .lock()
1915 .unwrap_or_else(PoisonError::into_inner)
1916 .take();
1917 if let Some(task) = task {
1918 task.token.cancel();
1919 let _ = task.handle.await;
1920 }
1921 let poller = self
1924 .poller
1925 .lock()
1926 .unwrap_or_else(PoisonError::into_inner)
1927 .take();
1928 if let Some(poller) = poller {
1929 poller.token.cancel();
1930 let _ = poller.handle.await;
1931 }
1932 let rate_limit_poller = self
1934 .rate_limit_poller
1935 .lock()
1936 .unwrap_or_else(PoisonError::into_inner)
1937 .take();
1938 if let Some(poller) = rate_limit_poller {
1939 poller.token.cancel();
1940 let _ = poller.handle.await;
1941 }
1942 }
1943}
1944
1945fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
1949 payload
1950 .get(field)
1951 .and_then(Value::as_str)
1952 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
1953}
1954
1955#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
1966struct GitStatus {
1967 #[serde(skip_serializing_if = "Option::is_none")]
1969 branch: Option<String>,
1970 #[serde(skip_serializing_if = "Option::is_none")]
1976 head_sha: Option<String>,
1977 #[serde(skip_serializing_if = "Option::is_none")]
1985 upstream_sha: Option<String>,
1986 #[serde(skip_serializing_if = "Option::is_none")]
1988 ahead: Option<usize>,
1989 #[serde(skip_serializing_if = "Option::is_none")]
1991 behind: Option<usize>,
1992 #[serde(skip_serializing_if = "Option::is_none")]
1997 main_repo: Option<String>,
1998 #[serde(skip_serializing_if = "is_false")]
2001 is_worktree: bool,
2002 #[serde(skip_serializing_if = "Option::is_none")]
2018 operation: Option<String>,
2019}
2020
2021#[allow(clippy::trivially_copy_pass_by_ref)]
2025fn is_false(b: &bool) -> bool {
2026 !*b
2027}
2028
2029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2034struct PollingLease {
2035 repo: String,
2036 expires_at: DateTime<Utc>,
2037}
2038
2039#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2045struct PollingPrefs {
2046 #[serde(default)]
2047 enabled: Vec<PollingLease>,
2048}
2049
2050fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2054 if let Some(parent) = path.parent() {
2055 crate::daemon::paths::ensure_dir_0700(parent)?;
2056 }
2057 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2058 crate::daemon::paths::write_file_0600(path, &json)
2059}
2060
2061#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2074struct PersistedBadge {
2075 number: u64,
2076 is_draft: bool,
2077 checks: PrCheckState,
2078 url: String,
2079 head_oid: String,
2080}
2081
2082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2087enum PersistedResolution {
2088 Pr(PersistedBadge),
2089 NoPr,
2090}
2091
2092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2094struct PersistedEntry {
2095 target: PrTarget,
2096 resolution: PersistedResolution,
2097}
2098
2099#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2104struct PersistedWatch {
2105 target: PrTarget,
2106 #[serde(default, skip_serializing_if = "Option::is_none")]
2107 upstream_sha: Option<String>,
2108}
2109
2110#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2115struct PrCachePrefs {
2116 #[serde(default)]
2117 entries: Vec<PersistedEntry>,
2118 #[serde(default)]
2119 watched: Vec<PersistedWatch>,
2120 #[serde(default, skip_serializing_if = "Option::is_none")]
2121 polled_at: Option<DateTime<Utc>>,
2122}
2123
2124impl PersistedResolution {
2125 fn from_resolution(r: &PrResolution) -> Self {
2127 match r {
2128 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2129 number: b.number,
2130 is_draft: b.is_draft,
2131 checks: b.checks,
2132 url: b.url.clone(),
2133 head_oid: b.head_oid.clone(),
2134 }),
2135 PrResolution::NoPr => Self::NoPr,
2136 }
2137 }
2138
2139 fn into_resolution(self) -> PrResolution {
2141 match self {
2142 Self::Pr(b) => PrResolution::Pr(PrBadge {
2143 number: b.number,
2144 is_draft: b.is_draft,
2145 checks: b.checks,
2146 url: b.url,
2147 head_oid: b.head_oid,
2148 }),
2149 Self::NoPr => PrResolution::NoPr,
2150 }
2151 }
2152}
2153
2154fn pr_cache_prefs_from(
2157 entries: Vec<(PrTarget, PrResolution)>,
2158 watched: &[PrWatch],
2159 polled_at: DateTime<Utc>,
2160) -> PrCachePrefs {
2161 let mut entries: Vec<PersistedEntry> = entries
2162 .into_iter()
2163 .map(|(target, resolution)| PersistedEntry {
2164 target,
2165 resolution: PersistedResolution::from_resolution(&resolution),
2166 })
2167 .collect();
2168 entries.sort_by(|a, b| a.target.cmp(&b.target));
2171 let mut watched: Vec<PersistedWatch> = watched
2172 .iter()
2173 .map(|w| PersistedWatch {
2174 target: w.target.clone(),
2175 upstream_sha: w.upstream_sha.clone(),
2176 })
2177 .collect();
2178 watched.sort_by(|a, b| a.target.cmp(&b.target));
2179 PrCachePrefs {
2180 entries,
2181 watched,
2182 polled_at: Some(polled_at),
2183 }
2184}
2185
2186fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2189 if let Some(parent) = path.parent() {
2190 crate::daemon::paths::ensure_dir_0700(parent)?;
2191 }
2192 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2193 crate::daemon::paths::write_file_0600(path, &json)
2194}
2195
2196fn persist_pr_cache(
2202 path: &Path,
2203 pr_cache: &PrStatusCache,
2204 watched: &[PrWatch],
2205 polled_at: DateTime<Utc>,
2206) {
2207 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2208 if let Err(err) = write_pr_cache(path, &prefs) {
2209 let at = path.display();
2210 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2211 }
2212}
2213
2214#[derive(Debug, Clone)]
2220struct PrWarmStart {
2221 watched: Vec<PrWatch>,
2223 polled_at: DateTime<Utc>,
2226}
2227
2228#[derive(Debug, Clone)]
2233struct OpenPrEntry {
2234 at: Instant,
2235 prs: Vec<Value>,
2236}
2237
2238#[derive(Debug)]
2249struct OpenPrCache {
2250 entries: Mutex<HashMap<String, OpenPrEntry>>,
2251 ttl: Duration,
2252}
2253
2254impl OpenPrCache {
2255 fn new(ttl: Duration) -> Self {
2256 Self {
2257 entries: Mutex::new(HashMap::new()),
2258 ttl,
2259 }
2260 }
2261
2262 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2265 self.entries
2266 .lock()
2267 .unwrap_or_else(PoisonError::into_inner)
2268 .get(key)
2269 .filter(|e| e.at.elapsed() < self.ttl)
2270 .map(|e| e.prs.clone())
2271 }
2272
2273 fn store(&self, key: String, prs: Vec<Value>) {
2275 self.entries
2276 .lock()
2277 .unwrap_or_else(PoisonError::into_inner)
2278 .insert(
2279 key,
2280 OpenPrEntry {
2281 at: Instant::now(),
2282 prs,
2283 },
2284 );
2285 }
2286}
2287
2288fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2293 let output = crate::github_metrics::run_gh(
2294 bin,
2295 [
2296 "pr",
2297 "list",
2298 "--repo",
2299 slug,
2300 "--state",
2301 "open",
2302 "--json",
2303 OPEN_PR_JSON_FIELDS,
2304 "--limit",
2305 OPEN_PR_LIST_LIMIT,
2306 ],
2307 "pr list",
2308 None,
2309 )
2310 .with_context(|| {
2311 format!(
2312 "failed to run {} (is the GitHub CLI installed?)",
2313 bin.display()
2314 )
2315 })?;
2316 if !output.status.success() {
2317 let stderr = String::from_utf8_lossy(&output.stderr);
2318 bail!("gh pr list failed: {}", stderr.trim());
2319 }
2320 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2321 Value::Array(arr) => Ok(arr),
2322 _ => bail!("gh pr list did not return a JSON array"),
2323 }
2324}
2325
2326fn git_status(folder: &Path) -> GitStatus {
2332 git_status_impl(folder, true)
2333}
2334
2335fn git_status_cheap(folder: &Path) -> GitStatus {
2343 git_status_impl(folder, false)
2344}
2345
2346fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2352 let Ok(repo) = Repository::discover(folder) else {
2353 return GitStatus::default();
2354 };
2355 let base = GitStatus {
2361 main_repo: main_repo_name(repo.commondir()),
2362 is_worktree: repo.is_worktree(),
2363 operation: operation_slug(repo.state()),
2364 ..GitStatus::default()
2365 };
2366 let Ok(head) = repo.head() else {
2367 return base;
2369 };
2370 let base = GitStatus {
2376 head_sha: head.target().map(|oid| oid.to_string()),
2377 ..base
2378 };
2379 let Some(name) = head
2383 .shorthand()
2384 .ok()
2385 .filter(|_| head.is_branch())
2386 .map(str::to_string)
2387 else {
2388 return base;
2389 };
2390 let branch = git2::Branch::wrap(head);
2395 let upstream_sha = upstream_target(&branch);
2398 let (ahead, behind) = if with_ahead_behind {
2401 match upstream_ahead_behind(&repo, &branch) {
2402 Some((ahead, behind)) => (Some(ahead), Some(behind)),
2403 None => (None, None),
2404 }
2405 } else {
2406 (None, None)
2407 };
2408 GitStatus {
2409 branch: Some(name),
2410 upstream_sha,
2411 ahead,
2412 behind,
2413 ..base
2414 }
2415}
2416
2417fn operation_slug(state: RepositoryState) -> Option<String> {
2428 let slug = match state {
2429 RepositoryState::Clean => return None,
2430 RepositoryState::Merge => "merge",
2431 RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2432 RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2433 RepositoryState::Bisect => "bisect",
2434 RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2435 RepositoryState::RebaseInteractive => "rebase-interactive",
2436 RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2437 };
2438 Some(slug.to_string())
2439}
2440
2441fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2451 Some(branch.upstream().ok()?.get().target()?.to_string())
2452}
2453
2454fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2461 let repo = Repository::discover(folder).ok()?;
2462 let head = repo.head().ok()?;
2463 if !head.is_branch() {
2464 return None;
2465 }
2466 let branch = git2::Branch::wrap(head);
2467 upstream_ahead_behind(&repo, &branch)
2468}
2469
2470fn main_repo_name(commondir: &Path) -> Option<String> {
2476 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2477 if file_name == ".git" {
2478 commondir
2480 .parent()
2481 .and_then(Path::file_name)
2482 .map(|n| n.to_string_lossy().into_owned())
2483 } else {
2484 Some(
2486 file_name
2487 .strip_suffix(".git")
2488 .unwrap_or(&file_name)
2489 .to_string(),
2490 )
2491 }
2492}
2493
2494fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2497 let upstream = branch.upstream().ok()?;
2498 let local_oid = branch.get().target()?;
2499 let upstream_oid = upstream.get().target()?;
2500 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2501}
2502
2503#[derive(Serialize)]
2509struct EnrichedEntry<'a> {
2510 #[serde(flatten)]
2511 entry: &'a WindowEntry,
2512 #[serde(flatten)]
2513 git: GitStatus,
2514}
2515
2516fn enriched_entry(entry: &WindowEntry) -> Value {
2521 let git = entry
2522 .folders
2523 .first()
2524 .map(|folder| git_status(folder))
2525 .unwrap_or_default();
2526 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2527}
2528
2529async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2533 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2534 .await
2535 .unwrap_or_default()
2536}
2537
2538#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2544struct GithubIdentity {
2545 owner: String,
2547 name: String,
2549}
2550
2551#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2567struct TreeWorktree {
2568 path: String,
2570 #[serde(skip_serializing_if = "Option::is_none")]
2572 branch: Option<String>,
2573 #[serde(skip_serializing_if = "Option::is_none")]
2578 head_sha: Option<String>,
2579 #[serde(skip_serializing_if = "Option::is_none")]
2585 upstream_sha: Option<String>,
2586 is_main: bool,
2588 open: bool,
2590 #[serde(skip_serializing_if = "Option::is_none")]
2593 window_key: Option<String>,
2594 #[serde(skip_serializing_if = "Option::is_none")]
2600 pr: Option<PrBadge>,
2601 #[serde(skip_serializing_if = "is_false")]
2611 pr_none: bool,
2612 #[serde(skip_serializing_if = "Option::is_none")]
2617 operation: Option<String>,
2618 #[serde(skip_serializing_if = "is_false")]
2627 rebasing: bool,
2628}
2629
2630#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2634struct TreeRepo {
2635 main_repo: String,
2637 #[serde(skip_serializing_if = "Option::is_none")]
2639 github: Option<GithubIdentity>,
2640 root: String,
2642 #[serde(skip_serializing_if = "is_false")]
2650 polling_enabled: bool,
2651 worktrees: Vec<TreeWorktree>,
2654}
2655
2656fn github_identity(url: &str) -> Option<GithubIdentity> {
2663 let url = url.trim();
2664 let rest = [
2666 "https://github.com/",
2667 "http://github.com/",
2668 "ssh://git@github.com/",
2669 "git://github.com/",
2670 "git@github.com:",
2671 ]
2672 .iter()
2673 .find_map(|prefix| url.strip_prefix(prefix))?;
2674 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2675 let rest = rest.trim_end_matches('/');
2676 let mut parts = rest.splitn(2, '/');
2677 let owner = parts.next()?.trim();
2678 let name = parts.next()?.trim();
2679 if owner.is_empty() || name.is_empty() || name.contains('/') {
2681 return None;
2682 }
2683 Some(GithubIdentity {
2684 owner: owner.to_string(),
2685 name: name.to_string(),
2686 })
2687}
2688
2689fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2692 if let Ok(origin) = repo.find_remote("origin") {
2693 if let Some(id) = origin.url().ok().and_then(github_identity) {
2694 return Some(id);
2695 }
2696 }
2697 let names = repo.remotes().ok();
2701 names
2702 .iter()
2703 .flat_map(|arr| arr.iter())
2704 .flatten()
2705 .flatten()
2706 .filter_map(|name| repo.find_remote(name).ok())
2707 .find_map(|remote| remote.url().ok().and_then(github_identity))
2708}
2709
2710fn canonical(path: &Path) -> PathBuf {
2714 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2715}
2716
2717fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2722 let mut index = HashMap::new();
2723 for entry in entries {
2724 for folder in &entry.folders {
2725 index
2726 .entry(canonical(folder))
2727 .or_insert_with(|| entry.key.clone());
2728 }
2729 }
2730 index
2731}
2732
2733fn worktree_entry(
2738 path: &Path,
2739 is_main: bool,
2740 open_index: &HashMap<PathBuf, String>,
2741 rebasing: &HashSet<PathBuf>,
2742) -> TreeWorktree {
2743 let status = git_status_cheap(path);
2744 let canonical = canonical(path);
2745 let window_key = open_index.get(&canonical).cloned();
2746 TreeWorktree {
2747 path: path.display().to_string(),
2748 branch: status.branch,
2749 head_sha: status.head_sha,
2750 upstream_sha: status.upstream_sha,
2751 is_main,
2752 open: window_key.is_some(),
2753 window_key,
2754 pr: None,
2757 pr_none: false,
2758 operation: status.operation,
2759 rebasing: rebasing.contains(&canonical),
2762 }
2763}
2764
2765fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2794 for repo in repos {
2795 if let Some(github) = &repo.github {
2796 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
2797 }
2798 }
2799}
2800
2801fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
2802 for repo in repos {
2803 if !repo.polling_enabled {
2807 continue;
2808 }
2809 let Some(github) = repo.github.clone() else {
2810 continue;
2811 };
2812 for worktree in &mut repo.worktrees {
2813 let Some(branch) = &worktree.branch else {
2814 continue;
2815 };
2816 match pr_cache.get(&github.owner, &github.name, branch) {
2817 Some(PrResolution::Pr(mut badge)) => {
2818 if badge.is_stale_for(worktree.head_sha.as_deref()) {
2819 badge.checks = PrCheckState::Pending;
2820 }
2821 worktree.pr = Some(badge);
2822 }
2823 Some(PrResolution::NoPr) => worktree.pr_none = true,
2824 None => {}
2825 }
2826 }
2827 }
2828}
2829
2830fn repo_tree(
2836 discovered: &Repository,
2837 open_index: &HashMap<PathBuf, String>,
2838 rebasing: &HashSet<PathBuf>,
2839) -> Option<TreeRepo> {
2840 let commondir = canonical(discovered.commondir());
2843 let main_root = commondir.parent()?.to_path_buf();
2844 let main_repo = Repository::open(&main_root).ok()?;
2845
2846 let mut worktrees = vec![worktree_entry(&main_root, true, open_index, rebasing)];
2848 let names = main_repo.worktrees().ok();
2853 let mut linked: Vec<PathBuf> = names
2854 .iter()
2855 .flat_map(|arr| arr.iter())
2856 .flatten() .flatten() .filter_map(|name| main_repo.find_worktree(name).ok())
2859 .map(|wt| wt.path().to_path_buf())
2860 .collect();
2861 linked.sort();
2862 worktrees.extend(
2863 linked
2864 .iter()
2865 .map(|path| worktree_entry(path, false, open_index, rebasing)),
2866 );
2867
2868 Some(TreeRepo {
2869 main_repo: main_repo_name(&commondir)?,
2870 github: remote_github_identity(&main_repo),
2871 root: main_root.display().to_string(),
2872 polling_enabled: false,
2875 worktrees,
2876 })
2877}
2878
2879fn build_tree(
2885 folders: Vec<PathBuf>,
2886 windows: Vec<WindowEntry>,
2887 rebasing: HashSet<PathBuf>,
2888) -> Vec<TreeRepo> {
2889 let open_index = open_window_index(&windows);
2890 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
2891 for folder in &folders {
2892 let Ok(repo) = Repository::discover(folder) else {
2893 continue;
2894 };
2895 let key = canonical(repo.commondir());
2896 if repos.contains_key(&key) {
2897 continue;
2898 }
2899 if let Some(tree) = repo_tree(&repo, &open_index, &rebasing) {
2900 repos.insert(key, tree);
2901 }
2902 }
2903 repos.into_values().collect()
2904}
2905
2906async fn tree_repos(
2911 folders: Vec<PathBuf>,
2912 windows: Vec<WindowEntry>,
2913 pr_cache: Arc<PrStatusCache>,
2914 enabled_polling: HashSet<String>,
2915 rebasing: HashSet<PathBuf>,
2916) -> Vec<Value> {
2917 tokio::task::spawn_blocking(move || {
2918 let mut repos = build_tree(folders, windows, rebasing);
2919 stamp_polling(&mut repos, &enabled_polling);
2922 fold_pr_badges(&mut repos, &pr_cache);
2923 repos
2924 .iter()
2925 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
2926 .collect()
2927 })
2928 .await
2929 .unwrap_or_default()
2930}
2931
2932async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
2946 tokio::task::spawn_blocking(move || {
2947 let mut results = serde_json::Map::new();
2948 for path in paths {
2949 if let Some((ahead, behind)) = folder_ahead_behind(&path) {
2950 results.insert(
2951 path.display().to_string(),
2952 json!({ "ahead": ahead, "behind": behind }),
2953 );
2954 }
2955 }
2956 Value::Object(results)
2957 })
2958 .await
2959 .unwrap_or_else(|_| json!({}))
2960}
2961
2962struct WorktreesStream {
2976 cache: Arc<TreeSnapshotCache>,
2979 changes: watch::Receiver<u64>,
2983}
2984
2985#[async_trait]
2986impl ServiceStream for WorktreesStream {
2987 async fn changed(&mut self) {
2988 if self.changes.changed().await.is_err() {
2994 std::future::pending::<()>().await;
2995 }
2996 }
2997
2998 async fn snapshot(&self) -> Value {
2999 self.cache.snapshot().await
3004 }
3005}
3006
3007struct TreeSnapshotCache {
3031 registry: Arc<WorktreesRegistry>,
3034 pr_cache: Arc<PrStatusCache>,
3037 ttl: Duration,
3041 state: AsyncMutex<Option<CachedTree>>,
3046 computes: AtomicU64,
3050}
3051
3052struct CachedTree {
3055 generation: u64,
3059 computed_at: Instant,
3061 value: Arc<Value>,
3064}
3065
3066impl TreeSnapshotCache {
3067 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3071 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3072 }
3073
3074 fn with_ttl(
3077 registry: Arc<WorktreesRegistry>,
3078 pr_cache: Arc<PrStatusCache>,
3079 ttl: Duration,
3080 ) -> Self {
3081 Self {
3082 registry,
3083 pr_cache,
3084 ttl,
3085 state: AsyncMutex::new(None),
3086 computes: AtomicU64::new(0),
3087 }
3088 }
3089
3090 async fn snapshot(&self) -> Value {
3094 let mut state = self.state.lock().await;
3099 let generation = self.registry.change_generation();
3100 let fresh = state.as_ref().and_then(|cached| {
3103 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3104 .then(|| Arc::clone(&cached.value))
3105 });
3106 let value = if let Some(value) = fresh {
3107 value
3108 } else {
3109 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3110 self.computes.fetch_add(1, Ordering::Relaxed);
3111 *state = Some(CachedTree {
3112 generation,
3113 computed_at: Instant::now(),
3114 value: Arc::clone(&value),
3115 });
3116 value
3117 };
3118 drop(state);
3120 (*value).clone()
3121 }
3122
3123 #[cfg(test)]
3126 fn compute_count(&self) -> u64 {
3127 self.computes.load(Ordering::Relaxed)
3128 }
3129}
3130
3131async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3137 let folders = registry.open_folders();
3138 let windows = registry.list();
3139 let show_closed = registry.show_closed();
3140 let enabled_polling = registry.enabled_polling_repos();
3141 let rebasing = registry.rebasing_paths();
3144 json!({
3145 "repos": tree_repos(folders, windows, pr_cache, enabled_polling, rebasing).await,
3146 "show_closed": show_closed,
3147 })
3148}
3149
3150fn display_name(entry: &WindowEntry) -> String {
3153 if let Some(repo) = &entry.repo {
3154 return repo.clone();
3155 }
3156 if let Some(folder) = entry.folders.first() {
3157 return folder.file_name().map_or_else(
3158 || folder.display().to_string(),
3159 |n| n.to_string_lossy().into_owned(),
3160 );
3161 }
3162 "(no folder)".to_string()
3163}
3164
3165const REPO_SEP: char = '·';
3167const WORKTREE_SEP: char = '⑂';
3170
3171fn menu_items_for(
3176 entries: &[WindowEntry],
3177 rate_limit: Option<&RateLimitSnapshot>,
3178) -> Vec<MenuItem> {
3179 let mut items = Vec::new();
3180 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3184 if !label.is_empty() {
3185 items.push(MenuItem::Label(label));
3186 items.push(MenuItem::Separator);
3187 }
3188 }
3189 if entries.is_empty() {
3190 items.push(MenuItem::Label("No open windows".to_string()));
3191 } else {
3192 items.extend(window_menu_items(entries));
3193 }
3194 items
3195}
3196
3197fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3204 entries
3205 .iter()
3206 .map(|entry| {
3207 let label = window_label(entry);
3208 if entry.folders.is_empty() {
3209 MenuItem::Label(label)
3210 } else {
3211 MenuItem::Action(MenuAction {
3212 id: format!("focus:{}", entry.key),
3213 label,
3214 enabled: true,
3215 })
3216 }
3217 })
3218 .collect()
3219}
3220
3221fn window_label(entry: &WindowEntry) -> String {
3227 let status = entry
3228 .folders
3229 .first()
3230 .map(|folder| git_status(folder))
3231 .unwrap_or_default();
3232 let name = status
3235 .main_repo
3236 .clone()
3237 .unwrap_or_else(|| display_name(entry));
3238 if let Some(branch) = &status.branch {
3239 let sep = if status.is_worktree {
3240 WORKTREE_SEP
3241 } else {
3242 REPO_SEP
3243 };
3244 return match sync_indicator(status.ahead, status.behind) {
3245 Some(sync) => format!("{name} {sep} {branch} {sync}"),
3246 None => format!("{name} {sep} {branch}"),
3247 };
3248 }
3249 match &entry.title {
3251 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3252 _ => name,
3253 }
3254}
3255
3256fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3259 match (ahead, behind) {
3260 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3261 _ => None,
3262 }
3263}
3264
3265const CODE_BINARY_CANDIDATES: &[&str] = &[
3268 "/usr/local/bin/code",
3269 "/opt/homebrew/bin/code",
3270 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3271 "/usr/bin/code",
3272];
3273
3274pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3279 focus_window_with(&resolve_code_binary(), folder)
3280}
3281
3282fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3289 if !folder.is_absolute() {
3294 bail!(
3295 "refusing to focus a non-absolute folder path: {}",
3296 folder.display()
3297 );
3298 }
3299 if !folder.is_dir() {
3300 bail!("worktree folder no longer exists: {}", folder.display());
3301 }
3302 let child = Command::new(program)
3305 .arg(folder)
3306 .stdin(Stdio::null())
3307 .stdout(Stdio::null())
3308 .stderr(Stdio::null())
3309 .spawn()
3310 .with_context(|| {
3311 format!(
3312 "failed to launch `{}` to focus {}",
3313 program.display(),
3314 folder.display()
3315 )
3316 })?;
3317 std::thread::spawn(move || {
3319 let mut child = child;
3320 let _ = child.wait();
3321 });
3322 Ok(())
3323}
3324
3325fn resolve_code_binary() -> PathBuf {
3330 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3331}
3332
3333fn resolve_code_binary_from(
3336 env_override: Option<std::ffi::OsString>,
3337 candidates: &[&str],
3338) -> PathBuf {
3339 if let Some(path) = env_override {
3340 return PathBuf::from(path);
3341 }
3342 for candidate in candidates {
3343 let path = Path::new(candidate);
3344 if path.exists() {
3345 return path.to_path_buf();
3346 }
3347 }
3348 PathBuf::from("code")
3349}
3350
3351#[derive(Debug, Clone, Deserialize)]
3361struct RepositionRequest {
3362 reference_key: String,
3364 #[serde(default)]
3367 target_keys: Vec<String>,
3368 #[serde(default)]
3371 check: bool,
3372}
3373
3374fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3381 entries.iter().find(|entry| entry.key == key).map_or_else(
3382 || geometry::RegisteredWindow {
3383 key: key.to_string(),
3384 live: false,
3385 title: None,
3386 pid: None,
3387 },
3388 |entry| geometry::RegisteredWindow {
3389 key: entry.key.clone(),
3390 live: true,
3391 title: entry.title.clone(),
3392 pid: entry.pid,
3393 },
3394 )
3395}
3396
3397fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3403 let mut reply = json!({
3404 "trusted": report.trusted,
3405 "results": report.results,
3406 "moved": report.moved(),
3407 "skipped": report.skipped(),
3408 });
3409 if let Some(reference) = &report.reference {
3410 reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3411 }
3412 if let Some(blocked) = &report.blocked {
3413 reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3414 }
3415 if undoable {
3418 reply["undoable"] = Value::Bool(true);
3419 }
3420 reply
3421}
3422
3423fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3427 let phase = if !report.trusted {
3430 "untrusted"
3431 } else if report.blocked.is_some() {
3432 "blocked"
3433 } else if req.check {
3434 "check"
3435 } else {
3436 "apply"
3437 };
3438 tracing::info!(
3439 phase,
3440 reference = req.reference_key.as_str(),
3441 requested = req.target_keys.len(),
3442 blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3443 moved = report.moved(),
3444 skipped = report.skipped(),
3445 outcomes = outcome_kinds(report).as_str(),
3446 "reposition"
3447 );
3448}
3449
3450fn log_reposition_undo(report: &geometry::RepositionReport) {
3452 tracing::info!(
3453 trusted = report.trusted,
3454 restored = report.moved(),
3455 skipped = report.skipped(),
3456 outcomes = outcome_kinds(report).as_str(),
3457 "reposition undo"
3458 );
3459}
3460
3461fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3465 if report.results.is_empty() {
3466 return "-".to_string();
3467 }
3468 report
3469 .results
3470 .iter()
3471 .map(|r| r.outcome)
3472 .collect::<Vec<_>>()
3473 .join(",")
3474}
3475
3476#[derive(Debug, Clone, Deserialize)]
3486struct ReloadRequest {
3487 #[serde(default)]
3491 target_keys: Vec<String>,
3492}
3493
3494fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3498 let unknown = if unknown.is_empty() {
3502 "-".to_string()
3503 } else {
3504 unknown.join(",")
3505 };
3506 tracing::info!(
3507 requested,
3508 signalled,
3509 unknown = %unknown,
3510 "worktrees reload: signalled windows"
3511 );
3512}
3513
3514#[derive(Debug, Clone, Deserialize)]
3520struct CloseRequest {
3521 path: PathBuf,
3523 #[serde(default)]
3527 requester_key: Option<String>,
3528 #[serde(default)]
3532 remove: bool,
3533 #[serde(default)]
3536 confirmed: bool,
3537}
3538
3539#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3544struct Note {
3545 kind: String,
3547 detail: String,
3549}
3550
3551impl Note {
3552 fn new(kind: &str, detail: impl Into<String>) -> Self {
3553 Self {
3554 kind: kind.to_string(),
3555 detail: detail.into(),
3556 }
3557 }
3558}
3559
3560fn note_kinds(notes: &[Note]) -> String {
3564 if notes.is_empty() {
3565 return "-".to_string();
3566 }
3567 notes
3568 .iter()
3569 .map(|n| n.kind.as_str())
3570 .collect::<Vec<_>>()
3571 .join(",")
3572}
3573
3574fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3579 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3580}
3581
3582fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3588 tracing::error!(
3589 path = %path.display(),
3590 "worktrees close: {phase} failed: {err:#}"
3591 );
3592 err
3593}
3594
3595fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3606 match removed {
3607 Ok(Removal::Pruned) => {
3608 tracing::info!(
3609 path = %path.display(),
3610 outcome = "pruned",
3611 "worktrees close: linked worktree pruned"
3612 );
3613 Ok(json!({ "removed": true }))
3614 }
3615 Ok(Removal::AlreadyGone) => {
3616 tracing::info!(
3617 path = %path.display(),
3618 outcome = "already-gone",
3619 "worktrees close: nothing to prune, worktree already removed"
3620 );
3621 Ok(json!({ "removed": true }))
3622 }
3623 Err(err) => {
3624 tracing::warn!(
3625 path = %path.display(),
3626 outcome = "failed",
3627 "worktrees close: worktree prune failed: {err:#}"
3628 );
3629 Err(err)
3630 }
3631 }
3632}
3633
3634fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3640 tracing::info!(
3641 path = %path.display(),
3642 window_key = window_key.unwrap_or("-"),
3643 removable = git.removable,
3644 is_main = git.is_main,
3645 open,
3646 risks = %note_kinds(&git.risks),
3647 "worktrees close: safety check"
3648 );
3649}
3650
3651fn log_executing(
3656 path: &Path,
3657 requester: Option<&str>,
3658 remove: bool,
3659 self_close: bool,
3660 cross_window: usize,
3661) {
3662 tracing::info!(
3663 path = %path.display(),
3664 requester = requester.unwrap_or("-"),
3665 remove,
3666 self_close,
3667 cross_window,
3668 "worktrees close: executing"
3669 );
3670}
3671
3672fn log_close_abort(path: &Path, err: &anyhow::Error) {
3676 tracing::warn!(
3677 path = %path.display(),
3678 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3679 );
3680}
3681
3682fn log_window_closed(path: &Path) {
3686 tracing::info!(
3687 path = %path.display(),
3688 "worktrees close: window closed, no removal"
3689 );
3690}
3691
3692#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3696struct SafetyReport {
3697 removable: bool,
3700 is_main: bool,
3702 open: bool,
3704 #[serde(skip_serializing_if = "Option::is_none")]
3706 window_key: Option<String>,
3707 window_folder_count: usize,
3710 risks: Vec<Note>,
3713 info: Vec<Note>,
3716}
3717
3718#[derive(Debug, Clone, PartialEq, Eq)]
3721struct GitSafety {
3722 is_main: bool,
3723 removable: bool,
3724 risks: Vec<Note>,
3725 info: Vec<Note>,
3726}
3727
3728#[derive(Debug, Clone, Deserialize)]
3736struct RebaseRequest {
3737 paths: Vec<PathBuf>,
3739 #[serde(default)]
3742 requester_key: Option<String>,
3743 #[serde(default)]
3745 check: bool,
3746 #[serde(default)]
3748 confirmed: bool,
3749 #[serde(default)]
3754 keep_conflicts: bool,
3755 #[serde(default)]
3758 autostash: bool,
3759 #[serde(default)]
3761 onto: Option<String>,
3762}
3763
3764impl RebaseRequest {
3765 fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
3767 worktree_rebase::RebaseOptions {
3768 onto: self.onto.clone(),
3769 autostash: self.autostash,
3770 dry_run: false,
3773 keep_conflicts: self.keep_conflicts,
3774 git_bin: Some(git_bin),
3775 }
3776 }
3777}
3778
3779async fn plan_rebase(
3783 selection: &Selection,
3784 opts: &worktree_rebase::RebaseOptions,
3785) -> Result<worktree_rebase::Plan> {
3786 let selection = selection.clone();
3787 let opts = opts.clone();
3788 tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
3789 .await
3790 .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
3791 .and_then(|inner| inner)
3792}
3793
3794fn rebase_reply(
3799 fetches: &[worktree_rebase::FetchOutcome],
3800 worktrees: &[worktree_rebase::WorktreeOutcome],
3801) -> Value {
3802 json!({ "fetches": fetches, "worktrees": worktrees })
3803}
3804
3805fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
3809 let pending = plan
3810 .worktrees
3811 .iter()
3812 .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
3813 .count();
3814 let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
3815 tracing::info!(
3816 requester = req.requester_key.as_deref().unwrap_or("-"),
3817 requested = req.paths.len(),
3818 pending,
3819 fetches = plan.fetches.len(),
3820 failed_fetches,
3821 "rebase check"
3822 );
3823}
3824
3825fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
3829 use worktree_rebase::RebaseResult;
3830 let mut rebased = 0;
3831 let mut conflicts = 0;
3832 let mut left_in_place = 0;
3833 let mut skipped = 0;
3834 for outcome in outcomes {
3835 match &outcome.result {
3836 RebaseResult::Rebased { .. } => rebased += 1,
3837 RebaseResult::Conflict {
3838 left_in_place: k, ..
3839 } => {
3840 conflicts += 1;
3841 if *k {
3842 left_in_place += 1;
3843 }
3844 }
3845 RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
3846 RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
3847 }
3848 }
3849 tracing::info!(
3850 requester = req.requester_key.as_deref().unwrap_or("-"),
3851 requested = req.paths.len(),
3852 rebased,
3853 conflicts,
3854 left_in_place,
3855 skipped,
3856 "rebase execute"
3857 );
3858}
3859
3860#[derive(Debug, Clone, Deserialize)]
3866struct MergeQueueRequest {
3867 paths: Vec<PathBuf>,
3869 #[serde(default)]
3872 requester_key: Option<String>,
3873 #[serde(default)]
3875 check: bool,
3876 #[serde(default)]
3878 confirmed: bool,
3879}
3880
3881#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3883struct PrRef {
3884 path: String,
3886 number: u64,
3888 url: String,
3890 branch: String,
3892}
3893
3894impl From<&Eligible> for PrRef {
3895 fn from(e: &Eligible) -> Self {
3896 Self {
3897 path: e.path.to_string_lossy().to_string(),
3898 number: e.number,
3899 url: e.url.clone(),
3900 branch: e.branch.clone(),
3901 }
3902 }
3903}
3904
3905#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3908struct Skip {
3909 path: String,
3910 kind: String,
3911 detail: String,
3912}
3913
3914impl Skip {
3915 fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
3916 Self {
3917 path: path.to_string_lossy().to_string(),
3918 kind: kind.to_string(),
3919 detail: detail.into(),
3920 }
3921 }
3922}
3923
3924#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3928struct EligibilityReport {
3929 eligible: Vec<PrRef>,
3930 skipped: Vec<Skip>,
3931}
3932
3933#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3935struct QueuedPr {
3936 path: String,
3937 number: u64,
3938 #[serde(skip_serializing_if = "is_false")]
3941 already_queued: bool,
3942}
3943
3944#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3947struct EnqueueFailure {
3948 path: String,
3949 number: u64,
3950 error: String,
3951}
3952
3953#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3956struct EnqueueResult {
3957 queued: Vec<QueuedPr>,
3958 skipped: Vec<Skip>,
3959 failed: Vec<EnqueueFailure>,
3960}
3961
3962#[derive(Debug)]
3965struct LocalOk {
3966 path: PathBuf,
3967 target: PrTarget,
3968 head_sha: String,
3969}
3970
3971#[derive(Debug)]
3973struct Eligible {
3974 path: PathBuf,
3975 number: u64,
3976 url: String,
3977 branch: String,
3978 pr_id: String,
3980 already_queued: bool,
3982}
3983
3984fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
3990 let Ok(repo) = Repository::discover(path) else {
3991 return Err(Skip::new(path, "not-a-repo", "not a git repository"));
3992 };
3993 let (dirty, untracked) = count_dirty_untracked(&repo);
3995 if dirty > 0 {
3996 return Err(Skip::new(
3997 path,
3998 "dirty",
3999 format!("{dirty} modified tracked file(s) — commit or stash first"),
4000 ));
4001 }
4002 if untracked > 0 {
4003 return Err(Skip::new(
4004 path,
4005 "untracked",
4006 format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4007 ));
4008 }
4009 let Ok(head) = repo.head() else {
4012 return Err(Skip::new(
4013 path,
4014 "no-commits",
4015 "the branch has no commits yet",
4016 ));
4017 };
4018 let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4019 return Err(Skip::new(
4020 path,
4021 "no-commits",
4022 "HEAD does not resolve to a commit",
4023 ));
4024 };
4025 let Some(branch_name) = head
4028 .shorthand()
4029 .ok()
4030 .filter(|_| head.is_branch())
4031 .map(str::to_string)
4032 else {
4033 return Err(Skip::new(
4034 path,
4035 "detached",
4036 "HEAD is detached — no branch to enqueue",
4037 ));
4038 };
4039 let branch = git2::Branch::wrap(head);
4040 let Some(upstream_sha) = upstream_target(&branch) else {
4042 return Err(Skip::new(
4043 path,
4044 "no-upstream",
4045 "the branch tracks no upstream — push it first",
4046 ));
4047 };
4048 if upstream_sha != head_sha {
4049 return Err(Skip::new(
4050 path,
4051 "unpushed",
4052 "local commits are not on the remote yet — push first",
4053 ));
4054 }
4055 if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4058 if ahead > 0 {
4059 return Err(Skip::new(
4060 path,
4061 "unpushed",
4062 format!("{ahead} unpushed commit(s) — push first"),
4063 ));
4064 }
4065 }
4066 let Some(id) = remote_github_identity(&repo) else {
4069 return Err(Skip::new(
4070 path,
4071 "no-github",
4072 "the repository has no github.com remote",
4073 ));
4074 };
4075 Ok(LocalOk {
4076 path: path.to_path_buf(),
4077 target: PrTarget {
4078 owner: id.owner,
4079 name: id.name,
4080 branch: branch_name,
4081 },
4082 head_sha,
4083 })
4084}
4085
4086fn is_conflicting(state: Option<&str>) -> bool {
4091 matches!(state, Some("CONFLICTING" | "DIRTY"))
4092}
4093
4094fn check_label(state: PrCheckState) -> &'static str {
4096 match state {
4097 PrCheckState::Success => "passing",
4098 PrCheckState::Failure => "failing",
4099 PrCheckState::Pending => "still running",
4100 PrCheckState::None => "not reported",
4101 }
4102}
4103
4104fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4109 tracing::info!(
4110 requester = req.requester_key.as_deref().unwrap_or("-"),
4111 requested = req.paths.len(),
4112 eligible,
4113 skipped,
4114 "merge-queue check"
4115 );
4116}
4117
4118fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4122 tracing::info!(
4123 requester = req.requester_key.as_deref().unwrap_or("-"),
4124 queued,
4125 failed,
4126 skipped,
4127 "merge-queue enqueue"
4128 );
4129}
4130
4131fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4142 let mut skipped = Vec::new();
4143 let mut locals = Vec::new();
4144 for path in paths {
4145 match evaluate_local(path) {
4146 Ok(ok) => locals.push(ok),
4147 Err(skip) => skipped.push(skip),
4148 }
4149 }
4150 if locals.is_empty() {
4151 return Ok((Vec::new(), skipped));
4152 }
4153 let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4154 let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4155 let mut eligible = Vec::new();
4156 for local in locals {
4157 let Some(info) = resolved.get(&local.target) else {
4158 skipped.push(Skip::new(
4159 &local.path,
4160 "no-pr",
4161 "no open PR heads this branch",
4162 ));
4163 continue;
4164 };
4165 if info.head_oid != local.head_sha {
4166 skipped.push(Skip::new(
4167 &local.path,
4168 "stale",
4169 "the open PR's head differs from the local head — re-check",
4170 ));
4171 } else if info.is_draft {
4172 skipped.push(Skip::new(
4173 &local.path,
4174 "draft",
4175 format!("PR #{} is a draft", info.number),
4176 ));
4177 } else if is_conflicting(info.merge_state.as_deref()) {
4178 skipped.push(Skip::new(
4179 &local.path,
4180 "conflicting",
4181 format!("PR #{} has merge conflicts", info.number),
4182 ));
4183 } else if info.checks != PrCheckState::Success {
4184 skipped.push(Skip::new(
4185 &local.path,
4186 "checks-failing",
4187 format!(
4188 "PR #{} checks are {}",
4189 info.number,
4190 check_label(info.checks)
4191 ),
4192 ));
4193 } else {
4194 eligible.push(Eligible {
4195 path: local.path,
4196 number: info.number,
4197 url: info.url.clone(),
4198 branch: local.target.branch.clone(),
4199 pr_id: info.pr_id.clone(),
4200 already_queued: info.already_queued,
4201 });
4202 }
4203 }
4204 Ok((eligible, skipped))
4205}
4206
4207fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4212 let mut queued = Vec::new();
4213 let mut failed = Vec::new();
4214 for e in eligible {
4215 let path = e.path.to_string_lossy().to_string();
4216 if e.already_queued {
4217 queued.push(QueuedPr {
4218 path,
4219 number: e.number,
4220 already_queued: true,
4221 });
4222 continue;
4223 }
4224 match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4225 Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4226 path,
4227 number: e.number,
4228 already_queued: false,
4229 }),
4230 Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4231 path,
4232 number: e.number,
4233 error: msg,
4234 }),
4235 Err(err) => failed.push(EnqueueFailure {
4236 path,
4237 number: e.number,
4238 error: format!("{err:#}"),
4239 }),
4240 }
4241 }
4242 (queued, failed)
4243}
4244
4245fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4249 let target = canonical(path);
4250 entries
4251 .iter()
4252 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4253 .map(|e| (e.key.clone(), e.folders.len()))
4254 .collect()
4255}
4256
4257const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4264
4265const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4268
4269async fn await_windows_closed(
4276 registry: &WorktreesRegistry,
4277 path: &Path,
4278 requester: Option<&str>,
4279 timeout: Duration,
4280 poll: Duration,
4281) -> Result<()> {
4282 let deadline = std::time::Instant::now() + timeout;
4283 loop {
4284 let entries = registry.list();
4288 let path = path.to_path_buf();
4289 let requester = requester.map(str::to_string);
4290 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4291 windows_with_path(&entries, &path)
4292 .into_iter()
4293 .map(|(k, _)| k)
4294 .filter(|k| requester.as_deref() != Some(k))
4295 .collect()
4296 })
4297 .await
4298 .unwrap_or_default();
4299
4300 if remaining.is_empty() {
4301 return Ok(());
4302 }
4303 if std::time::Instant::now() >= deadline {
4304 bail!("window(s) did not close in time: {}", remaining.join(", "));
4305 }
4306 tokio::time::sleep(poll).await;
4307 }
4308}
4309
4310fn git_safety(path: &Path) -> Result<GitSafety> {
4317 if !path.exists() {
4318 return Ok(GitSafety {
4319 is_main: false,
4320 removable: true,
4321 risks: vec![],
4322 info: vec![Note::new("already-removed", "worktree no longer exists")],
4323 });
4324 }
4325 let repo = Repository::open(path)
4326 .with_context(|| format!("not a git worktree: {}", path.display()))?;
4327 if !repo.is_worktree() {
4329 return Ok(GitSafety {
4330 is_main: true,
4331 removable: false,
4332 risks: vec![],
4333 info: vec![Note::new(
4334 "main-working-tree",
4335 "the repository's main working tree is never deleted",
4336 )],
4337 });
4338 }
4339
4340 let mut risks = Vec::new();
4341 let mut info = Vec::new();
4342
4343 let (dirty, untracked) = count_dirty_untracked(&repo);
4344 if dirty > 0 {
4345 risks.push(Note::new(
4346 "dirty",
4347 format!("{dirty} modified tracked file(s) would be lost"),
4348 ));
4349 }
4350 if untracked > 0 {
4351 risks.push(Note::new(
4352 "untracked",
4353 format!("{untracked} untracked file(s) would be lost"),
4354 ));
4355 }
4356
4357 let state = repo.state();
4359 if state != RepositoryState::Clean {
4360 risks.push(Note::new(
4361 "in-progress",
4362 format!("an in-progress {state:?} operation would be lost"),
4363 ));
4364 }
4365
4366 if repo.head_detached().unwrap_or(false) {
4370 let lost = unreachable_commit_count(&repo).unwrap_or(0);
4371 if lost > 0 {
4372 risks.push(Note::new(
4373 "unreachable-commits",
4374 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4375 ));
4376 }
4377 }
4378
4379 if let Some(ahead) = current_branch_ahead(&repo) {
4382 if ahead > 0 {
4383 info.push(Note::new(
4384 "unpushed",
4385 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4386 ));
4387 }
4388 }
4389
4390 Ok(GitSafety {
4391 is_main: false,
4392 removable: true,
4393 risks,
4394 info,
4395 })
4396}
4397
4398fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4405 let mut opts = StatusOptions::new();
4406 opts.include_untracked(true)
4407 .recurse_untracked_dirs(true)
4408 .include_ignored(false)
4409 .exclude_submodules(true);
4410 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4411 return (0, 0);
4412 };
4413 let tracked = Status::INDEX_NEW
4416 | Status::INDEX_MODIFIED
4417 | Status::INDEX_DELETED
4418 | Status::INDEX_RENAMED
4419 | Status::INDEX_TYPECHANGE
4420 | Status::WT_MODIFIED
4421 | Status::WT_DELETED
4422 | Status::WT_TYPECHANGE
4423 | Status::WT_RENAMED
4424 | Status::CONFLICTED;
4425 let mut dirty = 0;
4426 let mut untracked = 0;
4427 for entry in statuses.iter() {
4428 let s = entry.status();
4429 if s.contains(Status::WT_NEW) {
4430 untracked += 1;
4431 }
4432 if s.intersects(tracked) {
4433 dirty += 1;
4434 }
4435 }
4436 (dirty, untracked)
4437}
4438
4439fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4446 let head_oid = repo.head().ok()?.target()?;
4447 let mut walk = repo.revwalk().ok()?;
4448 walk.push(head_oid).ok()?;
4449 for reference in repo.references().ok()? {
4450 let Ok(reference) = reference else { continue };
4451 if matches!(reference.name(), Ok("HEAD")) {
4454 continue;
4455 }
4456 if let Some(oid) = reference.target() {
4457 let _ = walk.hide(oid);
4458 }
4459 }
4460 Some(walk.flatten().count())
4461}
4462
4463fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4467 let head = repo.head().ok()?;
4468 if !head.is_branch() {
4469 return None;
4470 }
4471 let branch = git2::Branch::wrap(head);
4472 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4473}
4474
4475fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4481 let names = main_repo.worktrees()?;
4482 names
4483 .iter()
4484 .flatten() .flatten() .find(|name| {
4487 main_repo
4488 .find_worktree(name)
4489 .is_ok_and(|wt| canonical(wt.path()) == target)
4490 })
4491 .map(str::to_string)
4492 .ok_or_else(|| {
4493 anyhow!(
4494 "worktree {} is not registered in {}",
4495 target.display(),
4496 main_repo.path().display()
4497 )
4498 })
4499}
4500
4501const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4510 Duration::from_millis(250),
4511 Duration::from_millis(500),
4512 Duration::from_secs(1),
4513 Duration::from_secs(1),
4514];
4515
4516fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4522 matches!(
4523 e.raw_os_error(),
4524 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4525 )
4526}
4527
4528fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4534 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4535}
4536
4537fn remove_dir_all_retrying_with(
4543 dir: &Path,
4544 backoff: &[Duration],
4545 mut remove: impl FnMut() -> std::io::Result<()>,
4546) -> Result<()> {
4547 let mut backoff = backoff.iter();
4548 loop {
4549 match remove() {
4550 Ok(()) => return Ok(()),
4551 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4552 Err(e) => {
4553 if is_transient_rmdir_error(&e) {
4554 if let Some(delay) = backoff.next() {
4555 std::thread::sleep(*delay);
4556 continue;
4557 }
4558 }
4559 return Err(e).with_context(|| {
4560 format!("failed to remove worktree directory {}", dir.display())
4561 });
4562 }
4563 }
4564 }
4565}
4566
4567fn is_orphaned_worktree(path: &Path) -> bool {
4576 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4579 return false;
4580 };
4581 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4582 return false;
4583 };
4584 let admin = Path::new(admin);
4585 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4587}
4588
4589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4594enum Removal {
4595 Pruned,
4597 AlreadyGone,
4599}
4600
4601fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4626 if !path.exists() {
4627 return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4628 }
4629 let repo = match Repository::open(path) {
4630 Ok(repo) => repo,
4631 Err(_) if is_orphaned_worktree(path) => {
4634 remove_dir_all_retrying(path)?;
4635 return Ok(Removal::Pruned);
4636 }
4637 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4638 };
4639 if !repo.is_worktree() {
4640 bail!(
4641 "refusing to delete the main working tree: {}",
4642 path.display()
4643 );
4644 }
4645 let commondir = canonical(repo.commondir());
4648 let main_root = commondir
4649 .parent()
4650 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
4651 .to_path_buf();
4652 drop(repo);
4654 let main_repo = Repository::open(&main_root)
4655 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
4656 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
4657 let worktree = main_repo.find_worktree(&name)?;
4658
4659 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
4661 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
4662 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
4663 }
4664
4665 remove_dir_all_retrying(path)?;
4668
4669 let mut opts = git2::WorktreePruneOptions::new();
4674 opts.valid(true).working_tree(false);
4675 worktree
4676 .prune(Some(&mut opts))
4677 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
4678 Ok(Removal::Pruned)
4679}
4680
4681fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
4697 let mut roots: Vec<PathBuf> = Vec::new();
4698 let mut push = |root: PathBuf| {
4699 if !roots.contains(&root) {
4700 roots.push(root);
4701 }
4702 };
4703 for ancestor in path.ancestors().skip(1) {
4705 if let Ok(repo) = Repository::open(ancestor) {
4706 if !repo.is_worktree() {
4707 if let Some(root) = canonical(repo.commondir()).parent() {
4708 push(root.to_path_buf());
4709 }
4710 }
4711 }
4712 }
4713 for folder in windows.iter().flat_map(|w| &w.folders) {
4714 if let Ok(repo) = Repository::discover(folder) {
4715 if let Some(root) = canonical(repo.commondir()).parent() {
4716 push(root.to_path_buf());
4717 }
4718 }
4719 }
4720 roots
4721}
4722
4723fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
4732 let target = canonical(path);
4733 for root in candidate_main_repos {
4734 let Ok(main_repo) = Repository::open(root) else {
4735 continue;
4736 };
4737 if main_repo.is_worktree() {
4739 continue;
4740 }
4741 let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
4743 continue;
4744 };
4745 let worktree = main_repo.find_worktree(&name)?;
4746 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
4747 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
4748 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
4749 }
4750 let mut opts = git2::WorktreePruneOptions::new();
4751 opts.valid(true).working_tree(false);
4752 worktree.prune(Some(&mut opts)).with_context(|| {
4753 format!(
4754 "failed to prune orphaned worktree metadata for {}",
4755 path.display()
4756 )
4757 })?;
4758 return Ok(Removal::Pruned);
4759 }
4760 Ok(Removal::AlreadyGone)
4761}
4762
4763#[cfg(test)]
4764#[allow(clippy::unwrap_used, clippy::expect_used)]
4765mod tests {
4766 use super::*;
4767 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
4768 use std::sync::MutexGuard;
4769
4770 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
4771 json!({
4772 "key": key,
4773 "folders": [folder],
4774 "repo": repo,
4775 "title": format!("{key}-title"),
4776 "pid": 1234,
4777 })
4778 }
4779
4780 fn windows_of(payload: &Value) -> &Vec<Value> {
4782 payload
4783 .get("windows")
4784 .and_then(Value::as_array)
4785 .expect("windows array")
4786 }
4787
4788 #[tokio::test]
4789 async fn name_and_unknown_op() {
4790 let svc = WorktreesService::new();
4791 assert_eq!(svc.name(), "worktrees");
4792 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
4793 }
4794
4795 #[tokio::test]
4796 async fn handle_routes_ops_and_shapes_payloads() {
4797 let svc = WorktreesService::new();
4798 let payload = svc.handle("list", Value::Null).await.unwrap();
4800 assert_eq!(payload, json!({ "windows": [] }));
4801
4802 let reply = svc
4804 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
4805 .await
4806 .unwrap();
4807 assert_eq!(reply, json!({ "ok": true }));
4808 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
4809 assert_eq!(windows.len(), 1);
4810 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
4811 assert!(windows[0].get("last_seen").is_some());
4812
4813 let known = svc
4815 .handle("heartbeat", json!({ "key": "w1" }))
4816 .await
4817 .unwrap();
4818 assert_eq!(known, json!({ "known": true }));
4819 let unknown = svc
4820 .handle("heartbeat", json!({ "key": "nope" }))
4821 .await
4822 .unwrap();
4823 assert_eq!(unknown, json!({ "known": false }));
4824
4825 let reloaded = svc
4827 .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
4828 .await
4829 .unwrap();
4830 assert_eq!(
4831 reloaded,
4832 json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
4833 );
4834 assert!(svc.registry.take_reload_pending("w1"));
4835
4836 let gone = svc
4838 .handle("unregister", json!({ "key": "w1" }))
4839 .await
4840 .unwrap();
4841 assert_eq!(gone, json!({ "removed": true }));
4842 let again = svc
4843 .handle("unregister", json!({ "key": "w1" }))
4844 .await
4845 .unwrap();
4846 assert_eq!(again, json!({ "removed": false }));
4847 }
4848
4849 #[derive(Clone)]
4861 struct StubBackend {
4862 trusted: bool,
4863 windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
4866 writes: Arc<Mutex<Vec<geometry::Frame>>>,
4868 }
4869
4870 impl StubBackend {
4871 fn new(trusted: bool) -> Self {
4874 let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
4875 title: title.to_string(),
4876 frame: geometry::Frame {
4877 x,
4878 y: 0.0,
4879 width,
4880 height: 600.0,
4881 },
4882 minimized: false,
4883 fullscreen: false,
4884 standard: true,
4885 focused: false,
4886 };
4887 Self {
4888 trusted,
4889 windows: Arc::new(Mutex::new(vec![
4890 window("plan.md — ref-tree", 0.0, 800.0),
4891 window("main.rs — other-tree", 900.0, 500.0),
4892 ])),
4893 writes: Arc::new(Mutex::new(Vec::new())),
4894 }
4895 }
4896
4897 fn writes(&self) -> Vec<geometry::Frame> {
4898 self.writes
4899 .lock()
4900 .unwrap_or_else(PoisonError::into_inner)
4901 .clone()
4902 }
4903
4904 fn frame_of(&self, index: usize) -> geometry::Frame {
4906 self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
4907 }
4908
4909 fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
4911 let clone = self.clone();
4912 move || clone
4913 }
4914 }
4915
4916 impl geometry::WindowBackend for StubBackend {
4917 fn trusted(&self) -> bool {
4918 self.trusted
4919 }
4920
4921 fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
4922 pids.iter()
4923 .filter(|p| **p == 11 || **p == 12)
4924 .map(|p| (*p, 900))
4925 .collect()
4926 }
4927
4928 fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
4929 if app_pid != 900 {
4930 return Ok(Vec::new());
4931 }
4932 Ok(self
4933 .windows
4934 .lock()
4935 .unwrap_or_else(PoisonError::into_inner)
4936 .clone())
4937 }
4938
4939 fn set_frame(
4940 &self,
4941 id: geometry::WindowId,
4942 frame: geometry::Frame,
4943 ) -> Result<geometry::Frame, String> {
4944 self.writes
4945 .lock()
4946 .unwrap_or_else(PoisonError::into_inner)
4947 .push(frame);
4948 let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
4949 let window = windows
4950 .get_mut(id.index)
4951 .ok_or_else(|| format!("no window at index {}", id.index))?;
4952 window.frame = frame;
4953 Ok(frame)
4954 }
4955 }
4956
4957 fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
4960 svc.registry.register(
4961 serde_json::from_value(json!({
4962 "key": key,
4963 "folders": [format!("/tmp/{key}")],
4964 "title": title,
4965 "pid": pid,
4966 }))
4967 .expect("valid register payload"),
4968 );
4969 }
4970
4971 #[tokio::test]
4972 async fn reposition_requires_a_resolvable_reference() {
4973 let svc = WorktreesService::new();
4974 assert!(svc.handle("reposition", json!({})).await.is_err());
4977 assert!(svc
4978 .handle("reposition", json!({ "reference_key": " " }))
4979 .await
4980 .is_err());
4981 assert!(svc
4982 .handle("reposition", json!({ "reference_key": "ghost" }))
4983 .await
4984 .is_err());
4985 }
4986
4987 #[tokio::test]
4988 async fn reposition_moves_targets_and_records_an_undo() {
4989 let svc = WorktreesService::new();
4990 register_window(&svc, "ref", "ref-tree", 11);
4991 register_window(&svc, "other", "other-tree", 12);
4992 let backend = StubBackend::new(true);
4993
4994 let reply = svc
4995 .reposition_with(
4996 serde_json::from_value(json!({
4997 "reference_key": "ref",
4998 "target_keys": ["other"],
4999 }))
5000 .unwrap(),
5001 backend.factory(),
5002 )
5003 .await
5004 .unwrap();
5005
5006 assert_eq!(reply["trusted"], json!(true));
5007 assert_eq!(reply["moved"], json!(1));
5008 assert_eq!(reply["skipped"], json!(0));
5009 assert_eq!(reply["undoable"], json!(true));
5010 assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5011 assert_eq!(reply["results"][0]["key"], json!("other"));
5012 assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5013 assert_eq!(backend.writes().len(), 1);
5015 assert_eq!(
5016 backend.writes()[0],
5017 backend.frame_of(0),
5018 "wrote the reference window's own frame"
5019 );
5020 assert_eq!(
5021 backend.frame_of(1),
5022 backend.frame_of(0),
5023 "the target now occupies the reference's frame"
5024 );
5025
5026 let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5030 assert_eq!(undone["moved"], json!(1));
5031 assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5032 assert!(undone.get("reference").is_none(), "undo has no reference");
5033 assert_eq!(
5034 backend.frame_of(1),
5035 geometry::Frame {
5036 x: 900.0,
5037 y: 0.0,
5038 width: 500.0,
5039 height: 600.0,
5040 },
5041 "restored to exactly the pre-move frame"
5042 );
5043
5044 let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5045 assert_eq!(
5046 again,
5047 json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5048 );
5049 }
5050
5051 #[tokio::test]
5052 async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5053 let svc = WorktreesService::new();
5054 register_window(&svc, "ref", "ref-tree", 11);
5055 register_window(&svc, "other", "other-tree", 12);
5056 let backend = StubBackend::new(true);
5057
5058 let reply = svc
5059 .reposition_with(
5060 serde_json::from_value(json!({
5061 "reference_key": "ref",
5062 "target_keys": ["other"],
5063 "check": true,
5064 }))
5065 .unwrap(),
5066 backend.factory(),
5067 )
5068 .await
5069 .unwrap();
5070
5071 assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5072 assert!(
5073 reply.get("undoable").is_none(),
5074 "a dry run leaves nothing to undo"
5075 );
5076 assert!(
5077 backend.writes().is_empty(),
5078 "a dry run must not touch a window"
5079 );
5080 }
5081
5082 #[tokio::test]
5083 async fn reposition_reports_a_missing_permission_as_data() {
5084 let svc = WorktreesService::new();
5085 register_window(&svc, "ref", "ref-tree", 11);
5086 register_window(&svc, "other", "other-tree", 12);
5087 let backend = StubBackend::new(false);
5088
5089 let reply = svc
5090 .reposition_with(
5091 serde_json::from_value(json!({
5092 "reference_key": "ref",
5093 "target_keys": ["other"],
5094 }))
5095 .unwrap(),
5096 backend.factory(),
5097 )
5098 .await
5099 .unwrap();
5100
5101 assert_eq!(reply["trusted"], json!(false));
5104 assert_eq!(reply["moved"], json!(0));
5105 assert!(backend.writes().is_empty());
5106 }
5107
5108 #[tokio::test]
5109 async fn a_stale_target_key_is_skipped_not_fatal() {
5110 let svc = WorktreesService::new();
5111 register_window(&svc, "ref", "ref-tree", 11);
5112 register_window(&svc, "other", "other-tree", 12);
5113 let backend = StubBackend::new(true);
5114
5115 let reply = svc
5116 .reposition_with(
5117 serde_json::from_value(json!({
5118 "reference_key": "ref",
5119 "target_keys": ["closed-since", "ref", "other"],
5122 }))
5123 .unwrap(),
5124 backend.factory(),
5125 )
5126 .await
5127 .unwrap();
5128
5129 let outcomes: Vec<&str> = reply["results"]
5130 .as_array()
5131 .unwrap()
5132 .iter()
5133 .map(|r| r["outcome"].as_str().unwrap())
5134 .collect();
5135 assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5136 assert_eq!(reply["moved"], json!(1));
5137 assert_eq!(reply["skipped"], json!(2));
5138 }
5139
5140 #[tokio::test]
5141 async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5142 let svc = WorktreesService::new();
5143 register_window(&svc, "ref", "twin", 11);
5146 register_window(&svc, "other", "other-tree", 12);
5147 let backend = StubBackend::new(true);
5150 {
5151 let mut windows = backend
5152 .windows
5153 .lock()
5154 .unwrap_or_else(PoisonError::into_inner);
5155 windows[0].title = "a.rs — twin".to_string();
5156 windows[1].title = "b.rs — twin".to_string();
5157 }
5158
5159 let reply = svc
5160 .reposition_with(
5161 serde_json::from_value(json!({
5162 "reference_key": "ref",
5163 "target_keys": ["other"],
5164 }))
5165 .unwrap(),
5166 backend.factory(),
5167 )
5168 .await
5169 .unwrap();
5170
5171 assert_eq!(reply["trusted"], json!(true));
5172 assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5173 assert!(
5174 reply["blocked"]["detail"]
5175 .as_str()
5176 .is_some_and(|d| d.contains("twin")),
5177 "the reason should name the ambiguous title: {reply}"
5178 );
5179 assert_eq!(reply["results"], json!([]), "no target is attempted");
5180 assert!(reply.get("undoable").is_none());
5181 assert!(backend.writes().is_empty());
5182
5183 let undone = svc
5185 .reposition_undo_with(StubBackend::new(true).factory())
5186 .await
5187 .unwrap();
5188 assert_eq!(undone["moved"], json!(0));
5189 }
5190
5191 #[tokio::test]
5192 async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5193 let svc = WorktreesService::new();
5194 let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5195 assert_eq!(reply["moved"], json!(0));
5196 assert_eq!(reply["results"], json!([]));
5197 }
5198
5199 #[test]
5200 fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5201 let empty = geometry::RepositionReport {
5202 trusted: true,
5203 blocked: None,
5204 reference: None,
5205 results: Vec::new(),
5206 undo: Vec::new(),
5207 };
5208 assert_eq!(outcome_kinds(&empty), "-");
5209 }
5210
5211 #[tokio::test]
5212 async fn handle_rejects_missing_or_empty_key() {
5213 let svc = WorktreesService::new();
5214 assert!(svc.handle("register", json!({})).await.is_err());
5216 assert!(svc
5217 .handle("register", json!({ "key": " " }))
5218 .await
5219 .is_err());
5220 assert!(svc.handle("heartbeat", json!({})).await.is_err());
5222 assert!(svc.handle("unregister", json!({})).await.is_err());
5223 }
5224
5225 #[test]
5226 fn display_name_prefers_repo_then_folder_basename() {
5227 let base = WindowEntry {
5228 key: "k".to_string(),
5229 folders: vec![PathBuf::from("/home/me/project")],
5230 repo: Some("my-repo".to_string()),
5231 title: None,
5232 pid: None,
5233 last_seen: Utc::now(),
5234 };
5235 assert_eq!(display_name(&base), "my-repo");
5236
5237 let no_repo = WindowEntry {
5238 repo: None,
5239 ..base.clone()
5240 };
5241 assert_eq!(display_name(&no_repo), "project");
5242
5243 let nothing = WindowEntry {
5244 repo: None,
5245 folders: vec![],
5246 ..base.clone()
5247 };
5248 assert_eq!(display_name(¬hing), "(no folder)");
5249
5250 let rootish = WindowEntry {
5253 repo: None,
5254 folders: vec![PathBuf::from("/")],
5255 ..base
5256 };
5257 assert_eq!(display_name(&rootish), "/");
5258 }
5259
5260 #[test]
5261 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5262 let now = Utc::now();
5263 let entries = vec![
5264 WindowEntry {
5269 key: "k2".to_string(),
5270 folders: vec![],
5271 repo: Some("solo".to_string()),
5272 title: Some("solo".to_string()),
5273 pid: None,
5274 last_seen: now,
5275 },
5276 WindowEntry {
5279 key: "k1".to_string(),
5280 folders: vec![PathBuf::from("/tmp/a")],
5281 repo: Some("repo".to_string()),
5282 title: Some("a branch".to_string()),
5283 pid: None,
5284 last_seen: now,
5285 },
5286 ];
5287 let items = window_menu_items(&entries);
5288 assert_eq!(items.len(), 2);
5290 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5291
5292 let action = items
5295 .iter()
5296 .find_map(|i| match i {
5297 MenuItem::Action(a) => Some(a),
5298 _ => None,
5299 })
5300 .expect("a focus action");
5301 assert_eq!(action.id, "focus:k1");
5302 assert_eq!(action.label, "repo · a branch");
5303
5304 let labels: Vec<&str> = items
5306 .iter()
5307 .filter_map(|i| match i {
5308 MenuItem::Label(t) => Some(t.as_str()),
5309 _ => None,
5310 })
5311 .collect();
5312 assert_eq!(labels, vec!["solo"]);
5313 }
5314
5315 #[tokio::test]
5316 async fn menu_and_status_shapes() {
5317 let svc = WorktreesService::new();
5318 let menu = svc.menu();
5320 assert_eq!(menu.title, "Worktrees");
5321 assert!(matches!(
5322 menu.items.first(),
5323 Some(MenuItem::Label(text)) if text == "No open windows"
5324 ));
5325 let status = svc.status().await;
5326 assert_eq!(status.name, "worktrees");
5327 assert!(status.healthy);
5328 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5329
5330 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5333 .await
5334 .unwrap();
5335 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5336 .await
5337 .unwrap();
5338 svc.handle(
5339 "register",
5340 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5341 )
5342 .await
5343 .unwrap();
5344 let status = svc.status().await;
5345 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5346
5347 let menu = svc.menu();
5348 assert_eq!(menu.items.len(), 3);
5350 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5351 let action_ids: Vec<&str> = menu
5352 .items
5353 .iter()
5354 .filter_map(|i| match i {
5355 MenuItem::Action(a) => Some(a.id.as_str()),
5356 _ => None,
5357 })
5358 .collect();
5359 assert!(action_ids.contains(&"focus:w1"));
5362 assert!(action_ids.contains(&"focus:w2"));
5363 assert!(!action_ids.contains(&"focus:w3"));
5364 }
5365
5366 #[test]
5367 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5368 let svc = WorktreesService::new();
5371 svc.start_menu_refresh();
5372 assert!(svc.refresh.lock().unwrap().is_none());
5373 }
5374
5375 #[tokio::test]
5376 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5377 let svc = WorktreesService::new();
5378 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5379 .await
5380 .unwrap();
5381 assert!(svc.menu_cache.lock().unwrap().is_none());
5383
5384 svc.start_menu_refresh();
5385 svc.start_menu_refresh();
5387
5388 let mut filled = false;
5390 for _ in 0..100 {
5391 if svc.menu_cache.lock().unwrap().is_some() {
5392 filled = true;
5393 break;
5394 }
5395 tokio::time::sleep(Duration::from_millis(10)).await;
5396 }
5397 assert!(filled, "background refresh should populate the menu cache");
5398
5399 let menu = svc.menu();
5401 assert_eq!(menu.title, "Worktrees");
5402 assert!(menu
5403 .items
5404 .iter()
5405 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5406
5407 svc.shutdown().await;
5409 assert!(svc.refresh.lock().unwrap().is_none());
5410 }
5411
5412 #[tokio::test]
5413 async fn default_constructs_an_empty_service() {
5414 let svc = WorktreesService::default();
5415 let payload = svc.handle("list", Value::Null).await.unwrap();
5416 assert_eq!(payload, json!({ "windows": [] }));
5417 }
5418
5419 #[tokio::test]
5422 async fn subscribe_streams_only_for_the_subscribe_op() {
5423 let svc = WorktreesService::new();
5424 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5428 assert!(svc.subscribe("list", &Value::Null).is_none());
5429 assert!(svc.subscribe("register", &Value::Null).is_none());
5430 assert!(svc.subscribe("bogus", &Value::Null).is_none());
5431 }
5432
5433 #[tokio::test]
5434 async fn subscribe_snapshot_matches_the_tree_op() {
5435 let dir = tempfile::tempdir().unwrap();
5436 let repo = init_repo(dir.path());
5437 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5438 repo.set_head("refs/heads/main").unwrap();
5439
5440 let svc = WorktreesService::new();
5441 let stream = svc
5442 .subscribe("subscribe", &Value::Null)
5443 .expect("subscribe stream");
5444 assert_eq!(
5447 stream.snapshot().await,
5448 json!({ "repos": [], "show_closed": true })
5449 );
5450
5451 svc.handle(
5454 "register",
5455 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5456 )
5457 .await
5458 .unwrap();
5459 let snap = stream.snapshot().await;
5460 let tree = svc.handle("tree", Value::Null).await.unwrap();
5461 assert_eq!(snap, tree);
5462 let repos = snap["repos"].as_array().expect("repos array");
5463 assert_eq!(repos.len(), 1);
5464 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5465 }
5466
5467 #[tokio::test]
5468 async fn subscribe_changed_wakes_on_register() {
5469 let svc = WorktreesService::new();
5470 let mut stream = svc
5471 .subscribe("subscribe", &Value::Null)
5472 .expect("subscribe stream");
5473 tokio::select! {
5475 () = stream.changed() => panic!("changed resolved with no registry change"),
5476 () = tokio::time::sleep(Duration::from_millis(50)) => {}
5477 }
5478 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5480 .await
5481 .unwrap();
5482 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5483 .await
5484 .expect("changed should resolve after a register");
5485 }
5486
5487 #[tokio::test]
5490 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5491 let reg = Arc::new(WorktreesRegistry::new());
5492 let cache = TreeSnapshotCache::with_ttl(
5494 reg,
5495 Arc::new(PrStatusCache::new()),
5496 Duration::from_secs(60),
5497 );
5498 let first = cache.snapshot().await;
5500 assert_eq!(cache.compute_count(), 1);
5501 let second = cache.snapshot().await;
5504 assert_eq!(
5505 cache.compute_count(),
5506 1,
5507 "an unchanged read must not rebuild"
5508 );
5509 assert_eq!(first, second);
5510 }
5511
5512 #[tokio::test]
5513 async fn tree_cache_single_flights_a_read_burst() {
5514 let reg = Arc::new(WorktreesRegistry::new());
5515 let cache = Arc::new(TreeSnapshotCache::with_ttl(
5516 reg,
5517 Arc::new(PrStatusCache::new()),
5518 Duration::from_secs(60),
5519 ));
5520 let mut handles = Vec::new();
5524 for _ in 0..16 {
5525 let cache = cache.clone();
5526 handles.push(tokio::spawn(async move { cache.snapshot().await }));
5527 }
5528 let mut results = Vec::new();
5529 for handle in handles {
5530 results.push(handle.await.unwrap());
5531 }
5532 assert_eq!(
5533 cache.compute_count(),
5534 1,
5535 "a concurrent read burst must build the tree once"
5536 );
5537 assert!(
5538 results.windows(2).all(|w| w[0] == w[1]),
5539 "every reader must observe the identical snapshot"
5540 );
5541 }
5542
5543 #[tokio::test]
5544 async fn tree_cache_rebuilds_on_registry_change() {
5545 let reg = Arc::new(WorktreesRegistry::new());
5546 let cache = TreeSnapshotCache::with_ttl(
5547 reg.clone(),
5548 Arc::new(PrStatusCache::new()),
5549 Duration::from_secs(60),
5550 );
5551 cache.snapshot().await;
5552 assert_eq!(cache.compute_count(), 1);
5553 assert!(reg.set_show_closed(false));
5557 cache.snapshot().await;
5558 assert_eq!(
5559 cache.compute_count(),
5560 2,
5561 "a generation bump must force a rebuild"
5562 );
5563 }
5564
5565 #[tokio::test]
5566 async fn tree_cache_rebuilds_after_ttl_expiry() {
5567 let reg = Arc::new(WorktreesRegistry::new());
5568 let cache =
5571 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5572 cache.snapshot().await;
5573 cache.snapshot().await;
5574 assert_eq!(
5575 cache.compute_count(),
5576 2,
5577 "an expired TTL must force a rebuild each read"
5578 );
5579 }
5580
5581 #[tokio::test]
5582 async fn subscribe_streams_share_one_build_per_generation() {
5583 let svc = WorktreesService::new();
5584 let s1 = svc
5585 .subscribe("subscribe", &Value::Null)
5586 .expect("subscribe stream");
5587 let s2 = svc
5588 .subscribe("subscribe", &Value::Null)
5589 .expect("subscribe stream");
5590 let a = s1.snapshot().await;
5593 let b = s2.snapshot().await;
5594 assert_eq!(a, b);
5595 assert_eq!(
5596 svc.tree_cache.compute_count(),
5597 1,
5598 "N streams on one generation must share a single build"
5599 );
5600 }
5601
5602 #[tokio::test]
5605 async fn set_show_closed_toggles_the_snapshot_field() {
5606 let svc = WorktreesService::new();
5607 assert_eq!(
5609 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5610 json!(true)
5611 );
5612 let reply = svc
5614 .handle("set-show-closed", json!({ "show_closed": false }))
5615 .await
5616 .unwrap();
5617 assert_eq!(reply, json!({ "ok": true }));
5618 assert_eq!(
5619 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5620 json!(false)
5621 );
5622 }
5623
5624 #[tokio::test]
5625 async fn set_show_closed_rejects_a_non_boolean_payload() {
5626 let svc = WorktreesService::new();
5627 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5628 assert!(svc
5629 .handle("set-show-closed", json!({ "show_closed": "yes" }))
5630 .await
5631 .is_err());
5632 }
5633
5634 #[tokio::test]
5635 async fn set_show_closed_wakes_the_subscription() {
5636 let svc = WorktreesService::new();
5637 let mut stream = svc
5638 .subscribe("subscribe", &Value::Null)
5639 .expect("subscribe stream");
5640 svc.handle("set-show-closed", json!({ "show_closed": false }))
5642 .await
5643 .unwrap();
5644 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5645 .await
5646 .expect("changed should resolve after a toggle flip");
5647 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
5649 }
5650
5651 #[tokio::test]
5652 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
5653 let dir = tempfile::tempdir().unwrap();
5656 github_repo(dir.path());
5657 let svc = WorktreesService::new();
5658 svc.handle(
5659 "register",
5660 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5661 )
5662 .await
5663 .unwrap();
5664
5665 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5666 assert!(
5667 repo.get("polling_enabled").is_none(),
5668 "default off omits the flag: {repo:?}"
5669 );
5670
5671 let reply = svc
5672 .handle(
5673 "set-polling",
5674 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5675 )
5676 .await
5677 .unwrap();
5678 assert_eq!(reply, json!({ "ok": true }));
5679 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5680 assert_eq!(repo["polling_enabled"], json!(true));
5681
5682 svc.handle(
5683 "set-polling",
5684 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5685 )
5686 .await
5687 .unwrap();
5688 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5689 assert!(repo.get("polling_enabled").is_none());
5690 }
5691
5692 #[tokio::test]
5693 async fn set_polling_rejects_missing_or_empty_fields() {
5694 let svc = WorktreesService::new();
5695 assert!(svc
5697 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
5698 .await
5699 .is_err());
5700 assert!(svc
5702 .handle("set-polling", json!({ "enabled": true }))
5703 .await
5704 .is_err());
5705 assert!(svc
5707 .handle(
5708 "set-polling",
5709 json!({ "owner": " ", "name": "n", "enabled": true })
5710 )
5711 .await
5712 .is_err());
5713 }
5714
5715 #[tokio::test]
5716 async fn set_polling_wakes_the_subscription() {
5717 let dir = tempfile::tempdir().unwrap();
5718 github_repo(dir.path());
5719 let svc = WorktreesService::new();
5720 svc.handle(
5721 "register",
5722 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5723 )
5724 .await
5725 .unwrap();
5726 let mut stream = svc
5727 .subscribe("subscribe", &Value::Null)
5728 .expect("subscribe stream");
5729 svc.handle(
5730 "set-polling",
5731 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5732 )
5733 .await
5734 .unwrap();
5735 tokio::time::timeout(Duration::from_secs(1), stream.changed())
5736 .await
5737 .expect("changed should resolve after enabling a repo");
5738 let repo = repos_of(&stream.snapshot().await)[0].clone();
5739 assert_eq!(repo["polling_enabled"], json!(true));
5740 }
5741
5742 #[tokio::test]
5743 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
5744 let dir = tempfile::tempdir().unwrap();
5748 let repo = github_repo(dir.path());
5749 let head = repo.head().unwrap().target().unwrap().to_string();
5750 let svc = WorktreesService::new();
5751 svc.handle(
5752 "register",
5753 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5754 )
5755 .await
5756 .unwrap();
5757 svc.registry.set_polling("rust-works", "omni-dev", true);
5758
5759 let mut badges = HashMap::new();
5760 badges.insert(
5761 PrTarget {
5762 owner: "rust-works".into(),
5763 name: "omni-dev".into(),
5764 branch: "main".into(),
5765 },
5766 pr(pending_badge(7, &head)),
5767 );
5768 svc.pr_cache.replace(badges);
5769
5770 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5771 assert_eq!(wt["pr"]["number"], json!(7));
5772
5773 svc.handle(
5774 "set-polling",
5775 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5776 )
5777 .await
5778 .unwrap();
5779 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5780 assert!(
5781 wt.get("pr").is_none(),
5782 "a disabled repo must carry no badge: {wt:?}"
5783 );
5784 }
5785
5786 #[tokio::test]
5787 async fn an_expired_lease_drops_the_flag_and_badges() {
5788 let dir = tempfile::tempdir().unwrap();
5792 let repo = github_repo(dir.path());
5793 let head = repo.head().unwrap().target().unwrap().to_string();
5794 let svc = WorktreesService::new();
5795 svc.handle(
5796 "register",
5797 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5798 )
5799 .await
5800 .unwrap();
5801 svc.registry.set_polling("rust-works", "omni-dev", true);
5802 let mut badges = HashMap::new();
5803 badges.insert(
5804 PrTarget {
5805 owner: "rust-works".into(),
5806 name: "omni-dev".into(),
5807 branch: "main".into(),
5808 },
5809 pr(pending_badge(7, &head)),
5810 );
5811 svc.pr_cache.replace(badges);
5812
5813 let snap = svc.handle("tree", Value::Null).await.unwrap();
5815 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
5816 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
5817 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
5818
5819 svc.registry.set_polling_expiry(
5821 "rust-works",
5822 "omni-dev",
5823 Utc::now() - chrono::Duration::minutes(1),
5824 );
5825
5826 let snap = svc.handle("tree", Value::Null).await.unwrap();
5827 let repo0 = &repos_of(&snap)[0];
5828 assert!(
5829 repo0.get("polling_enabled").is_none(),
5830 "expired lease drops the flag: {repo0:?}"
5831 );
5832 assert!(
5833 repo0["worktrees"][0].get("pr").is_none(),
5834 "expired lease drops the badge"
5835 );
5836 assert!(
5837 pr_targets_from_snapshot(&snap).is_empty(),
5838 "the poller no longer watches an expired repo"
5839 );
5840 }
5841
5842 #[tokio::test]
5843 async fn polling_prefs_persist_across_reloads_with_0600() {
5844 let dir = tempfile::tempdir().unwrap();
5847 let prefs = dir.path().join("worktrees-polling.json");
5848
5849 let svc = WorktreesService::new();
5850 svc.load_polling_prefs(prefs.clone());
5851 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
5852 svc.handle(
5853 "set-polling",
5854 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5855 )
5856 .await
5857 .unwrap();
5858 assert!(prefs.exists());
5859 #[cfg(unix)]
5860 {
5861 use std::os::unix::fs::PermissionsExt;
5862 assert_eq!(
5863 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
5864 0o600
5865 );
5866 }
5867
5868 let svc2 = WorktreesService::new();
5870 svc2.load_polling_prefs(prefs.clone());
5871 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
5872
5873 svc2.handle(
5875 "set-polling",
5876 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5877 )
5878 .await
5879 .unwrap();
5880 let svc3 = WorktreesService::new();
5881 svc3.load_polling_prefs(prefs);
5882 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
5883 }
5884
5885 #[test]
5886 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
5887 let dir = tempfile::tempdir().unwrap();
5892
5893 let corrupt = dir.path().join("worktrees-polling.json");
5895 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
5896 let svc = WorktreesService::new();
5897 svc.load_polling_prefs(corrupt);
5898 assert!(svc.registry.enabled_polling_repos().is_empty());
5899
5900 let as_dir = dir.path().join("is-a-directory");
5902 std::fs::create_dir(&as_dir).unwrap();
5903 let svc2 = WorktreesService::new();
5904 svc2.load_polling_prefs(as_dir);
5905 assert!(svc2.registry.enabled_polling_repos().is_empty());
5906 }
5907
5908 #[tokio::test]
5909 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
5910 let dir = tempfile::tempdir().unwrap();
5913 github_repo(dir.path());
5914 let bin_dir = tempfile::tempdir().unwrap();
5915 let marker = bin_dir.path().join("spawned");
5916 let fake = bin_dir.path().join("fake-gh");
5917 std::fs::write(
5918 &fake,
5919 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
5920 )
5921 .unwrap();
5922 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5923 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5924 std::fs::set_permissions(&fake, perms).unwrap();
5925
5926 let svc = WorktreesService::new();
5927 svc.handle(
5928 "register",
5929 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5930 )
5931 .await
5932 .unwrap();
5933 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5935 tokio::time::sleep(Duration::from_millis(200)).await;
5936 svc.shutdown().await;
5937 assert!(
5938 !marker.exists(),
5939 "a registered-but-not-enabled repo must drive zero gh"
5940 );
5941 }
5942
5943 #[tokio::test]
5944 async fn menu_action_rejects_unknown_and_missing_window() {
5945 let svc = WorktreesService::new();
5946 assert!(svc.menu_action("bogus").await.is_err());
5947 assert!(svc.menu_action("focus:nope").await.is_err());
5949 svc.shutdown().await;
5950 }
5951
5952 struct VscodeBinGuard(Option<std::ffi::OsString>);
5959 impl Drop for VscodeBinGuard {
5960 fn drop(&mut self) {
5961 match self.0.take() {
5962 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
5963 None => std::env::remove_var(VSCODE_BIN_ENV),
5964 }
5965 }
5966 }
5967
5968 #[tokio::test]
5969 async fn menu_action_focus_resolves_folder_and_spawns() {
5970 let dir = tempfile::tempdir().unwrap();
5971 let svc = WorktreesService::new();
5972 svc.handle(
5973 "register",
5974 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5975 )
5976 .await
5977 .unwrap();
5978
5979 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
5982 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
5983 svc.menu_action("focus:w1").await.unwrap();
5984 }
5985
5986 #[tokio::test]
5987 async fn open_rejects_missing_relative_or_nonexistent_path() {
5988 let svc = WorktreesService::new();
5989 assert!(svc.handle("open", json!({})).await.is_err());
5991 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
5992 assert!(svc
5995 .handle("open", json!({ "path": "relative/dir" }))
5996 .await
5997 .is_err());
5998 assert!(svc
5999 .handle("open", json!({ "path": "-flag" }))
6000 .await
6001 .is_err());
6002 assert!(svc
6005 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6006 .await
6007 .is_err());
6008 svc.shutdown().await;
6009 }
6010
6011 #[tokio::test]
6012 async fn open_focuses_an_existing_absolute_dir() {
6013 let dir = tempfile::tempdir().unwrap();
6014 let svc = WorktreesService::new();
6015 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6020 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6021 let reply = svc
6022 .handle("open", json!({ "path": dir.path() }))
6023 .await
6024 .unwrap();
6025 assert_eq!(reply, json!({ "ok": true }));
6026 svc.shutdown().await;
6027 }
6028
6029 #[test]
6030 fn focus_window_with_validates_folder_then_spawns() {
6031 let dir = tempfile::tempdir().unwrap();
6032 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6034 assert!(
6035 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6036 );
6037 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6039 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6041 }
6042
6043 #[test]
6044 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6045 assert_eq!(
6047 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6048 PathBuf::from("/custom/code")
6049 );
6050 let existing = tempfile::NamedTempFile::new().unwrap();
6052 let existing_path = existing.path().to_str().unwrap();
6053 assert_eq!(
6054 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6055 PathBuf::from(existing_path)
6056 );
6057 assert_eq!(
6059 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6060 PathBuf::from("code")
6061 );
6062 let _ = resolve_code_binary();
6064 }
6065
6066 fn init_repo(dir: &Path) -> Repository {
6071 let repo = Repository::init(dir).unwrap();
6072 let mut cfg = repo.config().unwrap();
6073 cfg.set_str("user.name", "Test").unwrap();
6074 cfg.set_str("user.email", "test@example.com").unwrap();
6075 repo
6076 }
6077
6078 fn empty_commit(
6081 repo: &Repository,
6082 refname: Option<&str>,
6083 parents: &[&git2::Commit<'_>],
6084 msg: &str,
6085 ) -> git2::Oid {
6086 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6087 let tree = repo
6088 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6089 .unwrap();
6090 repo.commit(refname, &sig, &sig, msg, &tree, parents)
6091 .unwrap()
6092 }
6093
6094 fn commit_file(
6099 repo: &Repository,
6100 refname: &str,
6101 name: &str,
6102 content: &[u8],
6103 msg: &str,
6104 ) -> git2::Oid {
6105 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6106 let blob = repo.blob(content).unwrap();
6107 let mut builder = repo.treebuilder(None).unwrap();
6108 builder.insert(name, blob, 0o100_644).unwrap();
6109 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6110 let parent = repo
6111 .refname_to_id(refname)
6112 .ok()
6113 .and_then(|oid| repo.find_commit(oid).ok());
6114 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6115 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6116 .unwrap()
6117 }
6118
6119 fn diverging_repo(dir: &Path) -> Repository {
6122 let repo = init_repo(dir);
6123 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6125 let a_commit = repo.find_commit(a).unwrap();
6126 let c = empty_commit(&repo, None, &[&a_commit], "C");
6128 repo.reference("refs/remotes/origin/main", c, true, "origin main")
6129 .unwrap();
6130 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6132 drop(a_commit);
6134 repo.set_head("refs/heads/main").unwrap();
6135 let mut cfg = repo.config().unwrap();
6137 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6138 .unwrap();
6139 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6140 .unwrap();
6141 cfg.set_str("branch.main.remote", "origin").unwrap();
6142 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6143 repo
6144 }
6145
6146 #[test]
6147 fn git_status_reads_branch_and_ahead_behind() {
6148 let dir = tempfile::tempdir().unwrap();
6149 let _repo = diverging_repo(dir.path());
6150 let status = git_status(dir.path());
6151 assert_eq!(status.branch.as_deref(), Some("main"));
6152 assert_eq!(status.ahead, Some(1));
6153 assert_eq!(status.behind, Some(1));
6154 assert_eq!(
6156 status.main_repo.as_deref(),
6157 dir.path().file_name().and_then(|n| n.to_str())
6158 );
6159 assert!(!status.is_worktree);
6160 }
6161
6162 #[test]
6163 fn git_status_empty_repo_is_unborn() {
6164 let dir = tempfile::tempdir().unwrap();
6168 init_repo(dir.path());
6169 let status = git_status(dir.path());
6170 assert_eq!(status.branch, None);
6171 assert_eq!(status.head_sha, None);
6173 assert_eq!(status.ahead, None);
6174 assert_eq!(status.behind, None);
6175 assert_eq!(
6176 status.main_repo.as_deref(),
6177 dir.path().file_name().and_then(|n| n.to_str())
6178 );
6179 assert!(!status.is_worktree);
6180 }
6181
6182 #[test]
6183 fn git_status_no_upstream_reports_branch_only() {
6184 let dir = tempfile::tempdir().unwrap();
6185 let repo = init_repo(dir.path());
6186 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6187 repo.set_head("refs/heads/main").unwrap();
6188 let status = git_status(dir.path());
6189 assert_eq!(status.branch.as_deref(), Some("main"));
6190 assert_eq!(status.ahead, None);
6192 assert_eq!(status.behind, None);
6193 assert_eq!(status.upstream_sha, None);
6196 }
6197
6198 #[test]
6199 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6200 let plain = tempfile::tempdir().unwrap();
6202 assert_eq!(git_status(plain.path()), GitStatus::default());
6203
6204 let dir = tempfile::tempdir().unwrap();
6207 let repo = init_repo(dir.path());
6208 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6209 repo.set_head_detached(a).unwrap();
6210 let status = git_status(dir.path());
6211 assert_eq!(status.branch, None);
6212 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6215 assert_eq!(status.ahead, None);
6216 assert_eq!(status.behind, None);
6217 assert_eq!(status.upstream_sha, None);
6220 assert_eq!(
6221 status.main_repo.as_deref(),
6222 dir.path().file_name().and_then(|n| n.to_str())
6223 );
6224 assert!(!status.is_worktree);
6225 }
6226
6227 #[test]
6230 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6231 let dir = tempfile::tempdir().unwrap();
6235 let repo = diverging_repo(dir.path());
6236 let status = git_status_cheap(dir.path());
6237 assert_eq!(status.branch.as_deref(), Some("main"));
6238 assert_eq!(status.ahead, None);
6239 assert_eq!(status.behind, None);
6240 assert_eq!(
6241 status.main_repo.as_deref(),
6242 dir.path().file_name().and_then(|n| n.to_str())
6243 );
6244 let head = repo.head().unwrap().target().unwrap();
6247 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6248 }
6249
6250 #[test]
6253 fn git_status_head_sha_tracks_new_commits() {
6254 let dir = tempfile::tempdir().unwrap();
6260 let repo = init_repo(dir.path());
6261 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6262 repo.set_head("refs/heads/main").unwrap();
6263 let before = git_status_cheap(dir.path());
6264 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6265
6266 let head = repo.find_commit(a).unwrap();
6267 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6268 let after = git_status_cheap(dir.path());
6269 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6270 assert_ne!(before.head_sha, after.head_sha);
6271 assert_eq!(before.branch, after.branch);
6274 }
6275
6276 fn simulate_push(repo: &Repository, oid: git2::Oid) {
6282 repo.reference("refs/remotes/origin/main", oid, true, "push")
6283 .unwrap();
6284 }
6285
6286 #[test]
6287 fn git_status_upstream_sha_tracks_a_push() {
6288 let dir = tempfile::tempdir().unwrap();
6295 let repo = diverging_repo(dir.path());
6296 let before = git_status(dir.path());
6297 assert_eq!(before.ahead, Some(1));
6298 assert_eq!(before.behind, Some(1));
6299
6300 let head = repo.head().unwrap().target().unwrap();
6301 simulate_push(&repo, head);
6302 let after = git_status(dir.path());
6303
6304 assert_eq!(
6306 after.upstream_sha.as_deref(),
6307 Some(head.to_string().as_str())
6308 );
6309 assert_ne!(before.upstream_sha, after.upstream_sha);
6310 assert_eq!(after.ahead, Some(0));
6311 assert_eq!(after.behind, Some(0));
6312 assert_eq!(before.branch, after.branch);
6316 assert_eq!(before.head_sha, after.head_sha);
6317 }
6318
6319 #[test]
6320 fn git_status_cheap_reports_upstream_sha() {
6321 let dir = tempfile::tempdir().unwrap();
6326 let repo = diverging_repo(dir.path());
6327 let status = git_status_cheap(dir.path());
6328 let upstream = repo
6329 .find_branch("origin/main", git2::BranchType::Remote)
6330 .unwrap()
6331 .get()
6332 .target()
6333 .unwrap();
6334 assert_eq!(
6335 status.upstream_sha.as_deref(),
6336 Some(upstream.to_string().as_str())
6337 );
6338 assert_eq!(status.ahead, None);
6339 assert_eq!(status.behind, None);
6340 }
6341
6342 #[test]
6343 fn folder_ahead_behind_computes_divergence_and_degrades() {
6344 let dir = tempfile::tempdir().unwrap();
6346 let _repo = diverging_repo(dir.path());
6347 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6348
6349 let no_up = tempfile::tempdir().unwrap();
6351 let repo = init_repo(no_up.path());
6352 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6353 repo.set_head("refs/heads/main").unwrap();
6354 assert_eq!(folder_ahead_behind(no_up.path()), None);
6355
6356 let detached = tempfile::tempdir().unwrap();
6358 let drepo = init_repo(detached.path());
6359 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6360 drepo.set_head_detached(a).unwrap();
6361 assert_eq!(folder_ahead_behind(detached.path()), None);
6362 let plain = tempfile::tempdir().unwrap();
6363 assert_eq!(folder_ahead_behind(plain.path()), None);
6364 }
6365
6366 #[tokio::test]
6367 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6368 let diverging = tempfile::tempdir().unwrap();
6369 let _d = diverging_repo(diverging.path());
6370 let no_up = tempfile::tempdir().unwrap();
6371 let repo = init_repo(no_up.path());
6372 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6373 repo.set_head("refs/heads/main").unwrap();
6374
6375 let svc = WorktreesService::new();
6376 let diverging_path = diverging.path().display().to_string();
6377 let no_up_path = no_up.path().display().to_string();
6378 let reply = svc
6379 .handle(
6380 "ahead-behind",
6381 json!({ "paths": [&diverging_path, &no_up_path] }),
6382 )
6383 .await
6384 .unwrap();
6385 let results = reply.get("results").unwrap();
6386 let d = results.get(diverging_path.as_str()).unwrap();
6388 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6389 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6390 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6392
6393 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6395 assert_eq!(empty.get("results"), Some(&json!({})));
6396 }
6397
6398 #[tokio::test]
6399 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6400 let dir = tempfile::tempdir().unwrap();
6402 let _repo = diverging_repo(dir.path());
6403 let svc = WorktreesService::new();
6404 svc.handle(
6405 "register",
6406 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6407 )
6408 .await
6409 .unwrap();
6410
6411 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6412 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6413 let main_wt = &worktrees[0];
6414 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6417 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6418 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6419 }
6420
6421 #[tokio::test]
6422 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6423 let dir = tempfile::tempdir().unwrap();
6428 let repo = init_repo(dir.path());
6429 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6430 repo.set_head("refs/heads/main").unwrap();
6431
6432 let svc = WorktreesService::new();
6433 svc.handle(
6434 "register",
6435 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6436 )
6437 .await
6438 .unwrap();
6439
6440 let before = svc.handle("tree", Value::Null).await.unwrap();
6441 let wt = &repos_of(&before)[0]["worktrees"][0];
6442 assert_eq!(
6443 wt.get("head_sha").and_then(Value::as_str),
6444 Some(a.to_string().as_str())
6445 );
6446
6447 let head = repo.find_commit(a).unwrap();
6450 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6451 let after = svc.handle("tree", Value::Null).await.unwrap();
6452 assert_eq!(
6453 repos_of(&after)[0]["worktrees"][0]
6454 .get("head_sha")
6455 .and_then(Value::as_str),
6456 Some(b.to_string().as_str())
6457 );
6458 assert_ne!(before, after, "a commit must be a visible snapshot delta");
6459 }
6460
6461 #[tokio::test]
6462 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6463 let dir = tempfile::tempdir().unwrap();
6466 init_repo(dir.path());
6467 let svc = WorktreesService::new();
6468 svc.handle(
6469 "register",
6470 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6471 )
6472 .await
6473 .unwrap();
6474 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6475 assert!(wt.get("head_sha").is_none(), "{wt:?}");
6476 }
6477
6478 #[tokio::test]
6481 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
6482 let dir = tempfile::tempdir().unwrap();
6488 let repo = diverging_repo(dir.path());
6489 let svc = WorktreesService::new();
6490 svc.handle(
6491 "register",
6492 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6493 )
6494 .await
6495 .unwrap();
6496
6497 let before = svc.handle("tree", Value::Null).await.unwrap();
6498 let head = repo.head().unwrap().target().unwrap();
6499 assert_ne!(
6500 repos_of(&before)[0]["worktrees"][0]
6501 .get("upstream_sha")
6502 .and_then(Value::as_str),
6503 Some(head.to_string().as_str()),
6504 "the fixture must start un-pushed for this to prove anything"
6505 );
6506
6507 simulate_push(&repo, head);
6509 let after = svc.handle("tree", Value::Null).await.unwrap();
6510 let wt = &repos_of(&after)[0]["worktrees"][0];
6511 assert_eq!(
6512 wt.get("upstream_sha").and_then(Value::as_str),
6513 Some(head.to_string().as_str())
6514 );
6515 assert_eq!(
6518 wt.get("head_sha").and_then(Value::as_str),
6519 repos_of(&before)[0]["worktrees"][0]
6520 .get("head_sha")
6521 .and_then(Value::as_str)
6522 );
6523 assert_ne!(before, after, "a push must be a visible snapshot delta");
6524 }
6525
6526 #[tokio::test]
6527 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
6528 let dir = tempfile::tempdir().unwrap();
6532 let repo = init_repo(dir.path());
6533 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6534 repo.set_head("refs/heads/main").unwrap();
6535 let svc = WorktreesService::new();
6536 svc.handle(
6537 "register",
6538 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6539 )
6540 .await
6541 .unwrap();
6542 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6543 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
6544 assert!(wt.get("head_sha").is_some(), "{wt:?}");
6546 }
6547
6548 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
6558 let guard = shim_lock();
6559 let path = dir.join("fake-gh");
6560 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
6561 (path, guard)
6562 }
6563
6564 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
6569 let guard = shim_lock();
6570 let path = dir.join("fake-gh");
6571 let counter = dir.join("gh-calls");
6572 write_exec_script(
6573 &path,
6574 &format!(
6575 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
6576 counter = counter.display()
6577 ),
6578 );
6579 (path, guard, counter)
6580 }
6581
6582 fn gh_spawn_count(counter: &Path) -> usize {
6585 std::fs::read(counter).map_or(0, |b| b.len())
6586 }
6587
6588 fn counted_gh_records(log: &Path) -> usize {
6593 std::fs::read_to_string(log)
6594 .unwrap_or_default()
6595 .lines()
6596 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
6597 .count()
6598 }
6599
6600 fn github_repo(dir: &Path) -> Repository {
6602 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
6603 }
6604
6605 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
6608 let repo = init_repo(dir);
6609 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6610 repo.set_head("refs/heads/main").unwrap();
6611 repo.remote("origin", url).unwrap();
6612 repo
6613 }
6614
6615 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
6620 PrBadge {
6621 number,
6622 is_draft: false,
6623 checks: PrCheckState::Pending,
6624 url: "u".into(),
6625 head_oid: head_oid.to_string(),
6626 }
6627 }
6628
6629 fn pr(badge: PrBadge) -> PrResolution {
6631 PrResolution::Pr(badge)
6632 }
6633
6634 #[test]
6635 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
6636 let snapshot = json!({"repos":[
6637 {
6638 "main_repo":"omni-dev",
6639 "github":{"owner":"rust-works","name":"omni-dev"},
6640 "root":"/r",
6641 "polling_enabled":true,
6644 "worktrees":[
6646 {"path":"/r","branch":"main","is_main":true,"open":true},
6647 {"path":"/w1","branch":"main","is_main":false,"open":true},
6648 {"path":"/w2","branch":"feature","is_main":false,"open":true},
6649 {"path":"/w3","is_main":false,"open":true}
6651 ]
6652 },
6653 {
6654 "main_repo":"local","root":"/l",
6656 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
6657 }
6658 ]});
6659 let targets = pr_targets_from_snapshot(&snapshot);
6660 assert_eq!(
6661 targets,
6662 vec![
6663 PrTarget {
6664 owner: "rust-works".into(),
6665 name: "omni-dev".into(),
6666 branch: "feature".into()
6667 },
6668 PrTarget {
6669 owner: "rust-works".into(),
6670 name: "omni-dev".into(),
6671 branch: "main".into()
6672 },
6673 ]
6674 );
6675 }
6676
6677 #[test]
6678 fn pr_targets_from_snapshot_is_empty_without_repos() {
6679 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
6680 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
6681 }
6682
6683 #[test]
6684 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
6685 for github in [
6688 json!({}),
6689 json!({"owner": "o"}),
6690 json!({"owner": 1, "name": 2}),
6691 ] {
6692 let snapshot = json!({"repos":[{
6693 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
6694 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6695 }]});
6696 assert!(
6697 pr_targets_from_snapshot(&snapshot).is_empty(),
6698 "{snapshot:?}"
6699 );
6700 }
6701 }
6702
6703 #[test]
6704 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
6705 for repo in [
6709 json!({
6710 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6711 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6712 }),
6713 json!({
6714 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6715 "polling_enabled":false,
6716 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6717 }),
6718 ] {
6719 let snapshot = json!({ "repos": [repo] });
6720 assert!(
6721 pr_targets_from_snapshot(&snapshot).is_empty(),
6722 "not-polled repo must yield no targets: {snapshot:?}"
6723 );
6724 }
6725 let enabled = json!({"repos":[{
6727 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6728 "polling_enabled":true,
6729 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6730 }]});
6731 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
6732 }
6733
6734 #[test]
6735 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
6736 let backoff = Duration::from_secs(600);
6737 assert!(pr_should_fetch(false, None, backoff));
6739 assert!(!pr_should_fetch(
6742 false,
6743 Some(Duration::from_secs(1)),
6744 backoff
6745 ));
6746 assert!(pr_should_fetch(false, Some(backoff), backoff));
6748 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
6749 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
6753 assert!(pr_should_fetch(
6754 true,
6755 Some(Duration::from_millis(1)),
6756 backoff
6757 ));
6758 }
6759
6760 #[test]
6761 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
6762 let base = Duration::from_secs(10);
6763 let fresh = Some(Duration::ZERO);
6764 let stale = Some(PENDING_FAST_WINDOW);
6765 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
6768 assert_eq!(
6769 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
6770 base
6771 );
6772 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
6775 assert_eq!(
6776 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
6777 PENDING_MAX_INTERVAL
6778 );
6779 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
6782 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
6784 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
6785 assert_eq!(
6787 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
6788 MAX_PR_POLL_INTERVAL
6789 );
6790 assert_eq!(
6791 next_pr_poll_delay(Duration::MAX, base, false, None),
6792 MAX_PR_POLL_INTERVAL
6793 );
6794 }
6795
6796 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
6798 PrWatch {
6799 target: PrTarget {
6800 owner: "rust-works".into(),
6801 name: "omni-dev".into(),
6802 branch: branch.into(),
6803 },
6804 upstream_sha: upstream.map(str::to_string),
6805 }
6806 }
6807
6808 #[test]
6809 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
6810 let a = watch("a", Some("111"));
6811 let b = watch("b", Some("222"));
6812 let ab = [a.clone(), b.clone()];
6813 let just_a = std::slice::from_ref(&a);
6814 let just_b = std::slice::from_ref(&b);
6815 assert!(!pr_watch_grew(&ab, &ab));
6817 assert!(pr_watch_grew(just_a, &ab));
6819 assert!(!pr_watch_grew(&ab, just_a));
6821 assert!(pr_watch_grew(&[], just_a));
6823 let a_pushed = [watch("a", Some("999"))];
6825 assert!(pr_watch_grew(just_a, &a_pushed));
6826 assert!(pr_watch_grew(just_a, just_b));
6828 }
6829
6830 #[test]
6831 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
6832 let base = Duration::from_secs(10);
6833 let over = RateLimitSnapshot {
6834 graphql: Some(rl_resource(90)),
6835 core: Some(rl_resource(3)),
6836 search: None,
6837 };
6838 let under = RateLimitSnapshot {
6839 graphql: Some(rl_resource(50)),
6840 core: Some(rl_resource(3)),
6841 search: None,
6842 };
6843 assert_eq!(budget_throttled_delay(base, None), base);
6845 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
6847 assert_eq!(
6849 budget_throttled_delay(base, Some(&over)),
6850 BUDGET_THROTTLE_INTERVAL
6851 );
6852 let long = BUDGET_THROTTLE_INTERVAL * 2;
6854 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
6855 }
6856
6857 #[test]
6858 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
6859 let target = PrTarget {
6863 owner: "rust-works".into(),
6864 name: "omni-dev".into(),
6865 branch: "main".into(),
6866 };
6867 let badge = PrResolution::Pr(PrBadge {
6868 number: 1337,
6869 is_draft: true,
6870 checks: PrCheckState::Pending,
6871 url: "http://x/1337".into(),
6872 head_oid: "deadbeef".into(),
6873 });
6874 let watched = vec![watch("main", Some("abc"))];
6875 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
6876 .unwrap()
6877 .with_timezone(&Utc);
6878 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
6879
6880 let json = serde_json::to_vec(&prefs).unwrap();
6881 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
6882 assert_eq!(back, prefs);
6883 assert_eq!(back.polled_at, Some(polled_at));
6884 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
6885 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
6887 }
6888
6889 #[test]
6890 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
6891 let target = PrTarget {
6895 owner: "rust-works".into(),
6896 name: "omni-dev".into(),
6897 branch: "feature".into(),
6898 };
6899 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
6900 let json = serde_json::to_vec(&prefs).unwrap();
6901 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
6902 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
6903 assert_eq!(
6904 back.entries[0].resolution.clone().into_resolution(),
6905 PrResolution::NoPr
6906 );
6907 }
6908
6909 #[test]
6910 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
6911 let dir = tempfile::tempdir().unwrap();
6916 let path = dir.path().join("pr-cache.json");
6917 let target = PrTarget {
6918 owner: "rust-works".into(),
6919 name: "omni-dev".into(),
6920 branch: "main".into(),
6921 };
6922 let mut prefs = pr_cache_prefs_from(
6923 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
6924 &[watch("main", None)],
6925 Utc::now(),
6926 );
6927 prefs.polled_at = None;
6928 write_pr_cache(&path, &prefs).unwrap();
6929
6930 let svc = WorktreesService::new();
6931 svc.load_pr_cache(path);
6932 assert!(
6933 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
6934 "the badge itself must still restore"
6935 );
6936 assert!(
6937 svc.pr_warm_start
6938 .lock()
6939 .unwrap_or_else(PoisonError::into_inner)
6940 .is_none(),
6941 "no poll time means a cold start, not a trusted warm one"
6942 );
6943 }
6944
6945 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
6949 tracing::subscriber::set_default(
6950 tracing_subscriber::fmt()
6951 .with_max_level(tracing::Level::WARN)
6952 .with_writer(std::io::sink)
6953 .finish(),
6954 )
6955 }
6956
6957 #[test]
6958 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
6959 let _trace = warn_subscriber();
6963 let dir = tempfile::tempdir().unwrap();
6964 let corrupt = dir.path().join("pr-cache.json");
6965 std::fs::write(&corrupt, b"not json").unwrap();
6966 let svc = WorktreesService::new();
6967 svc.load_pr_cache(corrupt.clone());
6968 assert!(svc.pr_cache.entries().is_empty());
6969 assert_eq!(
6970 svc.pr_cache_path
6971 .lock()
6972 .unwrap_or_else(PoisonError::into_inner)
6973 .as_deref(),
6974 Some(corrupt.as_path()),
6975 "the path must be stored even when the load fails, so persistence recovers"
6976 );
6977
6978 let svc = WorktreesService::new();
6981 svc.load_pr_cache(dir.path().to_path_buf());
6982 assert!(svc.pr_cache.entries().is_empty());
6983 }
6984
6985 #[test]
6986 fn persist_pr_cache_swallows_a_write_failure() {
6987 let _trace = warn_subscriber();
6991 let dir = tempfile::tempdir().unwrap();
6992 let blocker = dir.path().join("blocker");
6993 std::fs::write(&blocker, b"").unwrap();
6994 let path = blocker.join("pr-cache.json");
6996 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
6997 assert!(!path.exists());
6998 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7001 }
7002
7003 #[tokio::test]
7004 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7005 let dir = tempfile::tempdir().unwrap();
7006 let repo = github_repo(dir.path());
7007 let head = repo.head().unwrap().target().unwrap().to_string();
7008 let svc = WorktreesService::new();
7009 svc.handle(
7010 "register",
7011 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7012 )
7013 .await
7014 .unwrap();
7015 svc.registry.set_polling("rust-works", "omni-dev", true);
7018
7019 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7022 assert!(wt.get("pr").is_none(), "{wt:?}");
7023 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7024
7025 let mut badges = HashMap::new();
7027 badges.insert(
7028 PrTarget {
7029 owner: "rust-works".into(),
7030 name: "omni-dev".into(),
7031 branch: "main".into(),
7032 },
7033 pr(pending_badge(1337, &head)),
7034 );
7035 assert!(svc.pr_cache.replace(badges));
7036
7037 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7038 assert_eq!(wt["pr"]["number"], json!(1337));
7039 assert_eq!(wt["pr"]["checks"], json!("pending"));
7040 assert_eq!(wt["pr"]["isDraft"], json!(false));
7042 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7044 }
7045
7046 #[tokio::test]
7047 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7048 let dir = tempfile::tempdir().unwrap();
7053 let repo = github_repo(dir.path());
7054 let head = repo.head().unwrap().target().unwrap();
7055 repo.set_head_detached(head).unwrap();
7056
7057 let svc = WorktreesService::new();
7058 svc.handle(
7059 "register",
7060 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7061 )
7062 .await
7063 .unwrap();
7064 svc.registry.set_polling("rust-works", "omni-dev", true);
7067 let mut badges = HashMap::new();
7070 badges.insert(
7071 PrTarget {
7072 owner: "rust-works".into(),
7073 name: "omni-dev".into(),
7074 branch: "main".into(),
7075 },
7076 pr(pending_badge(1, &head.to_string())),
7077 );
7078 svc.pr_cache.replace(badges);
7079
7080 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7081 assert!(wt.get("branch").is_none(), "{wt:?}");
7082 assert_eq!(
7084 wt.get("head_sha").and_then(Value::as_str),
7085 Some(head.to_string().as_str())
7086 );
7087 assert!(wt.get("pr").is_none(), "{wt:?}");
7088 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7090 }
7091
7092 #[tokio::test]
7093 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7094 let dir = tempfile::tempdir().unwrap();
7095 github_repo(dir.path());
7096 let svc = WorktreesService::new();
7097 svc.handle(
7098 "register",
7099 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7100 )
7101 .await
7102 .unwrap();
7103 svc.registry.set_polling("rust-works", "omni-dev", true);
7106 let mut badges = HashMap::new();
7108 badges.insert(
7109 PrTarget {
7110 owner: "rust-works".into(),
7111 name: "omni-dev".into(),
7112 branch: "other".into(),
7113 },
7114 pr(pending_badge(1, "irrelevant")),
7115 );
7116 svc.pr_cache.replace(badges);
7117 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7118 assert!(wt.get("pr").is_none(), "{wt:?}");
7119 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7121 }
7122
7123 #[tokio::test]
7124 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7125 let dir = tempfile::tempdir().unwrap();
7129 github_repo(dir.path());
7130 let svc = WorktreesService::new();
7131 svc.handle(
7132 "register",
7133 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7134 )
7135 .await
7136 .unwrap();
7137 svc.registry.set_polling("rust-works", "omni-dev", true);
7140
7141 let mut resolutions = HashMap::new();
7142 resolutions.insert(
7143 PrTarget {
7144 owner: "rust-works".into(),
7145 name: "omni-dev".into(),
7146 branch: "main".into(),
7147 },
7148 PrResolution::NoPr,
7149 );
7150 assert!(svc.pr_cache.replace(resolutions));
7151
7152 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7153 assert_eq!(wt["pr_none"], json!(true));
7154 assert!(wt.get("pr").is_none(), "{wt:?}");
7156 }
7157
7158 #[tokio::test]
7159 async fn a_commit_does_not_drop_a_negative_resolution() {
7160 let dir = tempfile::tempdir().unwrap();
7165 let repo = github_repo(dir.path());
7166 let first = repo.head().unwrap().target().unwrap();
7167
7168 let svc = WorktreesService::new();
7169 svc.handle(
7170 "register",
7171 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7172 )
7173 .await
7174 .unwrap();
7175 svc.registry.set_polling("rust-works", "omni-dev", true);
7178
7179 let mut resolutions = HashMap::new();
7180 resolutions.insert(
7181 PrTarget {
7182 owner: "rust-works".into(),
7183 name: "omni-dev".into(),
7184 branch: "main".into(),
7185 },
7186 PrResolution::NoPr,
7187 );
7188 svc.pr_cache.replace(resolutions);
7189
7190 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7191 assert_eq!(wt["pr_none"], json!(true));
7192
7193 let head = repo.find_commit(first).unwrap();
7195 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7196
7197 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7198 assert_eq!(
7199 wt["pr_none"],
7200 json!(true),
7201 "a local commit must not drop the negative"
7202 );
7203 }
7204
7205 #[tokio::test]
7206 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7207 let bin_dir = tempfile::tempdir().unwrap();
7211 let marker = bin_dir.path().join("spawned");
7212 let fake = bin_dir.path().join("fake-gh");
7213 std::fs::write(
7214 &fake,
7215 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7216 )
7217 .unwrap();
7218 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7219 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7220 std::fs::set_permissions(&fake, perms).unwrap();
7221
7222 let svc = WorktreesService::new();
7223 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7224 tokio::time::sleep(Duration::from_millis(200)).await;
7225 svc.shutdown().await;
7226 assert!(
7227 !marker.exists(),
7228 "the poller must not spawn gh with no windows registered"
7229 );
7230 }
7231
7232 #[tokio::test]
7233 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7234 let dir = tempfile::tempdir().unwrap();
7237 let repo = github_repo(dir.path());
7238 let head = repo.head().unwrap().target().unwrap().to_string();
7239 let bin_dir = tempfile::tempdir().unwrap();
7240 let fake = bin_dir.path().join("fake-gh");
7241 std::fs::write(
7243 &fake,
7244 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7245 )
7246 .unwrap();
7247 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7248 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7249 std::fs::set_permissions(&fake, perms).unwrap();
7250
7251 let svc = WorktreesService::new();
7252 svc.handle(
7253 "register",
7254 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7255 )
7256 .await
7257 .unwrap();
7258 svc.registry.set_polling("rust-works", "omni-dev", true);
7261 let mut seeded = HashMap::new();
7263 seeded.insert(
7264 PrTarget {
7265 owner: "rust-works".into(),
7266 name: "omni-dev".into(),
7267 branch: "main".into(),
7268 },
7269 pr(pending_badge(7, &head)),
7270 );
7271 svc.pr_cache.replace(seeded);
7272
7273 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7274 tokio::time::sleep(Duration::from_millis(200)).await;
7275
7276 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7279 assert_eq!(wt["pr"]["number"], json!(7));
7280 assert!(wt.get("pr_none").is_none(), "{wt:?}");
7281 svc.shutdown().await;
7282 }
7283
7284 #[tokio::test]
7285 #[allow(clippy::await_holding_lock)]
7292 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7293 let dir = tempfile::tempdir().unwrap();
7299 github_repo(dir.path());
7300 let bin_dir = tempfile::tempdir().unwrap();
7301 let (fake, _shim) = fake_gh(
7302 bin_dir.path(),
7303 r#"{"data":{"r0":{"b0":{
7304 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7305 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7306 ]}}},
7307 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7308 }}}}"#,
7309 );
7310
7311 let svc = WorktreesService::new();
7312 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7314 tokio::time::sleep(Duration::from_millis(150)).await;
7315
7316 svc.handle(
7318 "register",
7319 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7320 )
7321 .await
7322 .unwrap();
7323 svc.registry.set_polling("rust-works", "omni-dev", true);
7326
7327 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7331 loop {
7332 if let Some(PrResolution::Pr(badge)) =
7333 svc.pr_cache.get("rust-works", "omni-dev", "main")
7334 {
7335 return badge;
7336 }
7337 tokio::time::sleep(Duration::from_millis(25)).await;
7338 }
7339 })
7340 .await
7341 .expect("a window opening must wake the poller out of its idle backoff");
7342 assert_eq!(badge.number, 99);
7343 svc.shutdown().await;
7344 }
7345
7346 #[tokio::test]
7347 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7348 let dir = tempfile::tempdir().unwrap();
7355 let repo = github_repo(dir.path());
7356 let first = repo.head().unwrap().target().unwrap();
7357
7358 let svc = WorktreesService::new();
7359 svc.handle(
7360 "register",
7361 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7362 )
7363 .await
7364 .unwrap();
7365 svc.registry.set_polling("rust-works", "omni-dev", true);
7368
7369 let mut badges = HashMap::new();
7371 badges.insert(
7372 PrTarget {
7373 owner: "rust-works".into(),
7374 name: "omni-dev".into(),
7375 branch: "main".into(),
7376 },
7377 pr(PrBadge {
7378 number: 1337,
7379 is_draft: false,
7380 checks: PrCheckState::Success,
7381 url: "u".into(),
7382 head_oid: first.to_string(),
7383 }),
7384 );
7385 svc.pr_cache.replace(badges);
7386
7387 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7388 assert_eq!(
7389 wt["pr"]["checks"],
7390 json!("success"),
7391 "green for its own commit"
7392 );
7393
7394 let head = repo.find_commit(first).unwrap();
7396 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7397
7398 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7399 assert_eq!(
7400 wt["pr"]["checks"],
7401 json!("pending"),
7402 "the previous commit's ✓ must not stand after a new commit"
7403 );
7404 assert_eq!(wt["pr"]["number"], json!(1337));
7407 }
7408
7409 #[test]
7410 fn is_stale_for_compares_the_commit_the_verdict_describes() {
7411 let badge = pending_badge(1, "aaa");
7412 assert!(!badge.is_stale_for(Some("aaa")));
7413 assert!(badge.is_stale_for(Some("bbb")));
7414 assert!(!badge.is_stale_for(None));
7416 }
7417
7418 #[test]
7419 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7420 let snap = |sha: &str| {
7425 json!({"repos":[{
7426 "main_repo":"omni-dev",
7427 "github":{"owner":"rust-works","name":"omni-dev"},
7428 "root":"/r",
7429 "polling_enabled":true,
7430 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7431 }]})
7432 };
7433 let before = pr_watch_from_snapshot(&snap("aaa"));
7434 let after = pr_watch_from_snapshot(&snap("bbb"));
7435 assert_eq!(before.len(), 1);
7436 assert_eq!(before[0].target, after[0].target);
7437 assert_eq!(before, after);
7439 assert!(!pr_watch_grew(&before, &after));
7440 }
7441
7442 #[test]
7443 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7444 let snap = |upstream: &str| {
7448 json!({"repos":[{
7449 "main_repo":"omni-dev",
7450 "github":{"owner":"rust-works","name":"omni-dev"},
7451 "root":"/r",
7452 "polling_enabled":true,
7453 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7454 "upstream_sha":upstream,"is_main":true,"open":true}]
7455 }]})
7456 };
7457 let before = pr_watch_from_snapshot(&snap("aaa"));
7458 let after = pr_watch_from_snapshot(&snap("bbb"));
7459 assert_eq!(before.len(), 1);
7461 assert_eq!(before[0].target, after[0].target);
7462 assert_ne!(before, after);
7463 assert!(pr_watch_grew(&before, &after));
7464 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
7466 assert!(!pr_watch_grew(
7467 &before,
7468 &pr_watch_from_snapshot(&snap("aaa"))
7469 ));
7470 }
7471
7472 #[test]
7473 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
7474 let snap = json!({"repos":[{
7477 "main_repo":"omni-dev",
7478 "github":{"owner":"rust-works","name":"omni-dev"},
7479 "root":"/r",
7480 "polling_enabled":true,
7481 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
7482 }]});
7483 let watch = pr_watch_from_snapshot(&snap);
7484 assert_eq!(watch.len(), 1);
7485 assert_eq!(watch[0].upstream_sha, None);
7486 }
7487
7488 #[test]
7489 fn start_pr_poller_is_a_noop_outside_a_runtime() {
7490 let svc = WorktreesService::new();
7491 svc.start_pr_poller();
7492 assert!(svc
7493 .poller
7494 .lock()
7495 .unwrap_or_else(PoisonError::into_inner)
7496 .is_none());
7497 }
7498
7499 #[tokio::test]
7500 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
7501 let svc = WorktreesService::new();
7502 svc.start_pr_poller_with(
7503 Duration::from_millis(50),
7504 Duration::from_millis(10),
7505 PathBuf::from("/bin/true"),
7506 );
7507 let token = svc
7508 .poller
7509 .lock()
7510 .unwrap_or_else(PoisonError::into_inner)
7511 .as_ref()
7512 .map(|t| t.token.clone())
7513 .expect("poller started");
7514
7515 token.cancel();
7518 svc.start_pr_poller_with(
7519 Duration::from_millis(50),
7520 Duration::from_millis(10),
7521 PathBuf::from("/bin/true"),
7522 );
7523 assert!(svc
7524 .poller
7525 .lock()
7526 .unwrap_or_else(PoisonError::into_inner)
7527 .as_ref()
7528 .is_some_and(|t| t.token.is_cancelled()));
7529
7530 svc.shutdown().await;
7531 assert!(svc
7532 .poller
7533 .lock()
7534 .unwrap_or_else(PoisonError::into_inner)
7535 .is_none());
7536 }
7537
7538 fn rl_resource(used: u64) -> RateLimitResource {
7542 RateLimitResource {
7543 used,
7544 limit: 100,
7545 remaining: 100 - used,
7546 percent: used as f64,
7547 reset: 0,
7548 }
7549 }
7550
7551 #[test]
7552 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
7553 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
7554 graphql: Some(rl_resource(graphql)),
7555 core: Some(rl_resource(core)),
7556 search: None,
7557 };
7558 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
7560 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
7562 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
7564 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
7566 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
7568 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
7570 }
7571
7572 #[test]
7573 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
7574 let svc = WorktreesService::new();
7575 svc.start_rate_limit_poller();
7576 assert!(svc
7577 .rate_limit_poller
7578 .lock()
7579 .unwrap_or_else(PoisonError::into_inner)
7580 .is_none());
7581 }
7582
7583 #[tokio::test]
7584 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
7585 let svc = WorktreesService::new();
7586 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
7587 let token = svc
7588 .rate_limit_poller
7589 .lock()
7590 .unwrap_or_else(PoisonError::into_inner)
7591 .as_ref()
7592 .map(|t| t.token.clone())
7593 .expect("poller started");
7594
7595 token.cancel();
7598 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
7599 assert!(svc
7600 .rate_limit_poller
7601 .lock()
7602 .unwrap_or_else(PoisonError::into_inner)
7603 .as_ref()
7604 .is_some_and(|t| t.token.is_cancelled()));
7605
7606 svc.shutdown().await;
7607 assert!(svc
7608 .rate_limit_poller
7609 .lock()
7610 .unwrap_or_else(PoisonError::into_inner)
7611 .is_none());
7612 }
7613
7614 #[tokio::test]
7615 #[allow(clippy::await_holding_lock)]
7617 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
7618 let bin_dir = tempfile::tempdir().unwrap();
7619 let (fake, _shim) = fake_gh(
7620 bin_dir.path(),
7621 r#"{"resources":{
7622 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
7623 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
7624 }}"#,
7625 );
7626 let svc = WorktreesService::new();
7627 svc.registry.set_polling("rust-works", "omni-dev", true);
7630 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
7631
7632 let snap = tokio::time::timeout(Duration::from_secs(30), async {
7635 loop {
7636 if let Some(snap) = svc.rate_limit_cache.get() {
7637 return snap;
7638 }
7639 tokio::time::sleep(Duration::from_millis(25)).await;
7640 }
7641 })
7642 .await
7643 .expect("poller should populate the cache through the fake gh");
7644 assert_eq!(snap.graphql.unwrap().used, 4100);
7645 assert_eq!(snap.core.unwrap().used, 27);
7646
7647 assert!(svc.rate_limit_cache().get().is_some());
7649
7650 svc.shutdown().await;
7651 assert!(svc
7652 .rate_limit_poller
7653 .lock()
7654 .unwrap_or_else(PoisonError::into_inner)
7655 .is_none());
7656 }
7657
7658 #[tokio::test]
7659 #[allow(clippy::await_holding_lock)]
7661 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
7662 let bin_dir = tempfile::tempdir().unwrap();
7665 let (fake, _shim, counter) = counting_fake_gh(
7666 bin_dir.path(),
7667 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
7668 );
7669 let svc = WorktreesService::new();
7670 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
7671
7672 tokio::time::sleep(Duration::from_millis(300)).await;
7674 assert_eq!(
7675 gh_spawn_count(&counter),
7676 0,
7677 "idle daemon must not poll (#1389, fix 8b)"
7678 );
7679 assert!(svc.rate_limit_cache.get().is_none());
7680
7681 svc.registry.set_polling("rust-works", "omni-dev", true);
7683 tokio::time::timeout(Duration::from_secs(30), async {
7684 loop {
7685 if svc.rate_limit_cache.get().is_some() {
7686 return;
7687 }
7688 tokio::time::sleep(Duration::from_millis(25)).await;
7689 }
7690 })
7691 .await
7692 .expect("an active lease should resume polling");
7693 assert!(gh_spawn_count(&counter) >= 1);
7694 svc.shutdown().await;
7695 }
7696
7697 #[tokio::test]
7698 async fn rate_limit_poller_survives_a_failing_gh() {
7699 let svc = WorktreesService::new();
7703 svc.registry.set_polling("rust-works", "omni-dev", true);
7704 svc.start_rate_limit_poller_with(
7705 Duration::from_millis(20),
7706 PathBuf::from("/no/such/gh/xyzzy"),
7707 );
7708 tokio::time::sleep(Duration::from_millis(150)).await;
7710 assert!(svc.rate_limit_cache.get().is_none());
7711 assert!(
7712 svc.rate_limit_poller
7713 .lock()
7714 .unwrap_or_else(PoisonError::into_inner)
7715 .is_some(),
7716 "the loop must survive a failing gh, not panic out"
7717 );
7718 svc.shutdown().await;
7719 }
7720
7721 #[test]
7722 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
7723 let svc = WorktreesService::new();
7724 let items = svc.menu().items;
7726 assert!(
7727 !items
7728 .iter()
7729 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
7730 "no github line before the first poll"
7731 );
7732
7733 svc.rate_limit_cache.replace(RateLimitSnapshot {
7735 graphql: Some(rl_resource(82)),
7736 core: Some(rl_resource(3)),
7737 search: None,
7738 });
7739 let items = svc.menu().items;
7740 assert!(
7741 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
7742 "expected the github line first, got {items:?}"
7743 );
7744 assert!(
7745 matches!(items.get(1), Some(MenuItem::Separator)),
7746 "expected a separator after the github line"
7747 );
7748 }
7749
7750 #[tokio::test]
7751 #[allow(clippy::await_holding_lock)]
7753 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
7754 let dir = tempfile::tempdir().unwrap();
7755 github_repo(dir.path());
7756 let bin_dir = tempfile::tempdir().unwrap();
7757 let (fake, _shim) = fake_gh(
7760 bin_dir.path(),
7761 r#"{"data":{"r0":{"b0":{
7762 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7763 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7764 ]}}},
7765 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7766 }}}}"#,
7767 );
7768 let svc = WorktreesService::new();
7769 svc.handle(
7770 "register",
7771 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7772 )
7773 .await
7774 .unwrap();
7775 svc.registry.set_polling("rust-works", "omni-dev", true);
7778 svc.start_pr_poller_with(
7779 Duration::from_millis(50),
7780 Duration::from_millis(10),
7781 fake.clone(),
7782 );
7783
7784 let badge = tokio::time::timeout(Duration::from_secs(30), async {
7788 loop {
7789 if let Some(PrResolution::Pr(badge)) =
7790 svc.pr_cache.get("rust-works", "omni-dev", "main")
7791 {
7792 return badge;
7793 }
7794 tokio::time::sleep(Duration::from_millis(25)).await;
7795 }
7796 })
7797 .await
7798 .expect("poller should resolve a badge through the fake gh");
7799 assert_eq!(badge.number, 1337);
7800 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
7801
7802 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7804 assert_eq!(wt["pr"]["number"], json!(1337));
7805
7806 svc.shutdown().await;
7808 let generation = svc.registry.change_generation();
7809 tokio::time::sleep(Duration::from_millis(120)).await;
7810 assert_eq!(
7811 svc.registry.change_generation(),
7812 generation,
7813 "no bumps after shutdown"
7814 );
7815 }
7816
7817 #[tokio::test]
7818 #[allow(clippy::await_holding_lock)]
7820 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
7821 let dir = tempfile::tempdir().unwrap();
7825 github_repo(dir.path());
7826 let bin_dir = tempfile::tempdir().unwrap();
7827 let (fake, _shim) = fake_gh(
7828 bin_dir.path(),
7829 r#"{"data":{
7830 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
7831 "resetAt":"2026-07-21T16:00:00Z"},
7832 "r0":{"b0":{
7833 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7834 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7835 ]}}},
7836 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7837 }}
7838 }}"#,
7839 );
7840 let svc = WorktreesService::new();
7841 svc.handle(
7842 "register",
7843 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7844 )
7845 .await
7846 .unwrap();
7847 svc.registry.set_polling("rust-works", "omni-dev", true);
7848 svc.start_pr_poller_with(
7851 Duration::from_millis(50),
7852 Duration::from_millis(10),
7853 fake.clone(),
7854 );
7855
7856 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
7857 loop {
7858 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
7859 return g;
7860 }
7861 tokio::time::sleep(Duration::from_millis(25)).await;
7862 }
7863 })
7864 .await
7865 .expect("the PR poll should fold its budget into the cache");
7866 assert_eq!(graphql.used, 123);
7867 assert_eq!(graphql.limit, 5000);
7868 assert_eq!(graphql.remaining, 4877);
7869 svc.shutdown().await;
7870 }
7871
7872 #[tokio::test]
7873 #[allow(clippy::await_holding_lock)]
7875 async fn pr_poll_counts_every_gh_call_exactly_once() {
7876 let dir = tempfile::tempdir().unwrap();
7883 github_repo(dir.path());
7884 let bin_dir = tempfile::tempdir().unwrap();
7885 let (fake, _shim, counter) = counting_fake_gh(
7886 bin_dir.path(),
7887 r#"{"data":{"r0":{"b0":{
7888 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7889 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7890 ]}}},
7891 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7892 }}}}"#,
7893 );
7894 let log = bin_dir.path().join("log.jsonl");
7895 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
7896
7897 let svc = WorktreesService::new();
7898 svc.handle(
7899 "register",
7900 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7901 )
7902 .await
7903 .unwrap();
7904 svc.registry.set_polling("rust-works", "omni-dev", true);
7905 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
7906
7907 tokio::time::timeout(Duration::from_secs(30), async {
7909 loop {
7910 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
7911 return;
7912 }
7913 tokio::time::sleep(Duration::from_millis(25)).await;
7914 }
7915 })
7916 .await
7917 .expect("poller should fetch through the fake gh");
7918
7919 svc.shutdown().await;
7921 let spawns = gh_spawn_count(&counter);
7922 let counted = counted_gh_records(&log);
7923 std::env::remove_var("OMNI_DEV_LOG_FILE");
7924 assert!(
7925 spawns >= 1,
7926 "the poll should have spent at least one gh call"
7927 );
7928 assert_eq!(
7929 counted, spawns,
7930 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
7931 );
7932 }
7933
7934 #[tokio::test]
7935 #[allow(clippy::await_holding_lock)]
7937 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
7938 let dir_a = tempfile::tempdir().unwrap();
7943 let dir_b = tempfile::tempdir().unwrap();
7944 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();
7947 let (fake, _shim, counter) = counting_fake_gh(
7949 bin_dir.path(),
7950 r#"{"data":{
7951 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7952 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
7953 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
7954 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
7955 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
7956 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
7957 }}"#,
7958 );
7959 let svc = WorktreesService::new();
7960 svc.registry.set_polling("rust-works", "omni-dev", true);
7963 svc.registry.set_polling("rust-works", "other-repo", true);
7964 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
7968 svc.handle(
7970 "register",
7971 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
7972 )
7973 .await
7974 .unwrap();
7975 tokio::time::sleep(Duration::from_millis(50)).await;
7979 svc.handle(
7980 "register",
7981 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
7982 )
7983 .await
7984 .unwrap();
7985
7986 tokio::time::timeout(Duration::from_secs(30), async {
7989 loop {
7990 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
7991 let b = svc
7992 .pr_cache
7993 .get("rust-works", "other-repo", "main")
7994 .is_some();
7995 if a && b {
7996 return;
7997 }
7998 tokio::time::sleep(Duration::from_millis(25)).await;
7999 }
8000 })
8001 .await
8002 .expect("the debounced fetch should resolve both repos");
8003
8004 svc.shutdown().await;
8005 assert_eq!(
8006 gh_spawn_count(&counter),
8007 1,
8008 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8009 );
8010 }
8011
8012 #[tokio::test]
8013 #[allow(clippy::await_holding_lock)]
8015 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8016 let dir = tempfile::tempdir().unwrap();
8021 github_repo(dir.path());
8022 let bin_dir = tempfile::tempdir().unwrap();
8023 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8024 let svc = WorktreesService::new();
8025 svc.registry.set_polling("rust-works", "omni-dev", true);
8026 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(50), fake);
8029 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8030 svc.handle("register", register.clone()).await.unwrap();
8031 for _ in 0..24 {
8035 tokio::time::sleep(Duration::from_millis(25)).await;
8036 svc.handle("register", register.clone()).await.unwrap();
8037 }
8038 let spawned_mid_drip = gh_spawn_count(&counter);
8039 svc.shutdown().await;
8040 assert!(
8041 spawned_mid_drip >= 1,
8042 "the deadline must force a fetch while the drip is still running (#1389, fix 2)"
8043 );
8044 }
8045
8046 #[tokio::test]
8047 #[allow(clippy::await_holding_lock)]
8049 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8050 let dir = tempfile::tempdir().unwrap();
8054 let repo = github_repo(dir.path());
8055 let head = repo.head().unwrap().target().unwrap().to_string();
8056 let bin_dir = tempfile::tempdir().unwrap();
8057 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8060
8061 let cache_path = bin_dir.path().join("pr-cache.json");
8065 let target = PrTarget {
8066 owner: "rust-works".into(),
8067 name: "omni-dev".into(),
8068 branch: "main".into(),
8069 };
8070 let prefs = pr_cache_prefs_from(
8071 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8072 &[watch("main", None)],
8073 Utc::now(),
8074 );
8075 write_pr_cache(&cache_path, &prefs).unwrap();
8076
8077 let svc = WorktreesService::new();
8078 svc.load_pr_cache(cache_path);
8079 svc.registry.set_polling("rust-works", "omni-dev", true);
8080 svc.handle(
8081 "register",
8082 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8083 )
8084 .await
8085 .unwrap();
8086 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8089
8090 let number = tokio::time::timeout(Duration::from_secs(30), async {
8092 loop {
8093 let tree = svc.handle("tree", Value::Null).await.unwrap();
8094 if let Some(n) = repos_of(&tree)
8095 .first()
8096 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8097 {
8098 return n;
8099 }
8100 tokio::time::sleep(Duration::from_millis(25)).await;
8101 }
8102 })
8103 .await
8104 .expect("the restored badge should render from the warm cache");
8105 assert_eq!(number, 1337);
8106
8107 tokio::time::sleep(Duration::from_millis(300)).await;
8109 svc.shutdown().await;
8110 assert_eq!(
8111 gh_spawn_count(&counter),
8112 0,
8113 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8114 );
8115 }
8116
8117 #[tokio::test]
8118 #[allow(clippy::await_holding_lock)]
8120 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8121 let dir = tempfile::tempdir().unwrap();
8126 github_repo(dir.path());
8127 let bin_dir = tempfile::tempdir().unwrap();
8128 let (fake, _shim) = fake_gh(
8129 bin_dir.path(),
8130 r#"{"data":{"r0":{"b0":{
8131 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8132 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8133 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8134 }}}}"#,
8135 );
8136 let svc = WorktreesService::new();
8137 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8140 svc.load_pr_cache(cache_path.clone());
8141 svc.registry.set_polling("rust-works", "omni-dev", true);
8142 svc.handle(
8143 "register",
8144 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8145 )
8146 .await
8147 .unwrap();
8148 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8149
8150 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8153 loop {
8154 if let Ok(bytes) = std::fs::read(&cache_path) {
8155 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8156 if !prefs.entries.is_empty() {
8157 return prefs;
8158 }
8159 }
8160 }
8161 tokio::time::sleep(Duration::from_millis(25)).await;
8162 }
8163 })
8164 .await
8165 .expect("a successful resolve should persist the cache file");
8166 svc.shutdown().await;
8167
8168 assert_eq!(prefs.entries[0].target.branch, "main");
8169 assert!(
8170 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8171 "{:?}",
8172 prefs.entries[0].resolution
8173 );
8174 assert_eq!(
8175 prefs.watched,
8176 vec![PersistedWatch {
8177 target: prefs.entries[0].target.clone(),
8178 upstream_sha: None
8179 }]
8180 );
8181 assert!(
8182 prefs.polled_at.is_some(),
8183 "the poll time is what ages the next warm start"
8184 );
8185 }
8186
8187 #[tokio::test]
8188 #[allow(clippy::await_holding_lock)]
8190 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8191 let bin_dir = tempfile::tempdir().unwrap();
8195 let (fake, _shim, counter) = counting_fake_gh(
8196 bin_dir.path(),
8197 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8198 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8199 );
8200 let svc = WorktreesService::new();
8201
8202 let prs = svc
8203 .open_prs_with("rust-works", "omni-dev", fake.clone())
8204 .await
8205 .expect("gh pr list should resolve");
8206 assert_eq!(prs.len(), 1);
8207 assert_eq!(prs[0]["number"], json!(42));
8208 assert_eq!(prs[0]["url"], json!("http://x/42"));
8209 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8210
8211 let again = svc
8213 .open_prs_with("rust-works", "omni-dev", fake.clone())
8214 .await
8215 .expect("cache hit should resolve");
8216 assert_eq!(again, prs);
8217 assert_eq!(
8218 gh_spawn_count(&counter),
8219 1,
8220 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8221 );
8222
8223 let reply = svc
8225 .handle(
8226 "open-prs",
8227 json!({ "owner": "rust-works", "name": "omni-dev" }),
8228 )
8229 .await
8230 .expect("open-prs op should route");
8231 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8232 assert!(svc
8233 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
8234 .await
8235 .is_err());
8236 }
8237
8238 #[test]
8239 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8240 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8245 assert!(
8246 err.to_string().contains("is the GitHub CLI installed"),
8247 "{err:#}"
8248 );
8249
8250 let bin_dir = tempfile::tempdir().unwrap();
8251 let _guard = shim_lock();
8252 let failing = bin_dir.path().join("fake-gh-fails");
8253 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8254 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8255 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8256 assert!(err.to_string().contains("boom"), "{err:#}");
8257
8258 let object = bin_dir.path().join("fake-gh-object");
8259 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8260 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8261 assert!(
8262 err.to_string().contains("did not return a JSON array"),
8263 "{err:#}"
8264 );
8265 }
8266
8267 #[tokio::test]
8268 #[allow(clippy::await_holding_lock)]
8270 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8271 let dir = tempfile::tempdir().unwrap();
8277 github_repo(dir.path());
8278 let bin_dir = tempfile::tempdir().unwrap();
8279 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8280
8281 let svc = WorktreesService::new();
8282 *svc.pr_warm_start
8286 .lock()
8287 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8288 watched: vec![],
8289 polled_at: Utc::now(),
8290 });
8291 svc.rate_limit_cache.replace(RateLimitSnapshot {
8293 graphql: Some(rl_resource(90)),
8294 core: Some(rl_resource(3)),
8295 search: None,
8296 });
8297 svc.registry.set_polling("rust-works", "omni-dev", true);
8298 svc.handle(
8299 "register",
8300 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8301 )
8302 .await
8303 .unwrap();
8304 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8307
8308 tokio::time::sleep(Duration::from_millis(300)).await;
8310 svc.shutdown().await;
8311 assert_eq!(
8312 gh_spawn_count(&counter),
8313 0,
8314 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8315 );
8316 }
8317
8318 #[tokio::test]
8319 #[allow(clippy::await_holding_lock)]
8321 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8322 let dir = tempfile::tempdir().unwrap();
8325 github_repo(dir.path());
8326 let bin_dir = tempfile::tempdir().unwrap();
8327 let (fake, _shim) = fake_gh(
8328 bin_dir.path(),
8329 r#"{"data":{"r0":{"b0":{
8330 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8331 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8332 ]}}},
8333 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8334 }}}}"#,
8335 );
8336 let svc = WorktreesService::new();
8337 svc.handle(
8338 "register",
8339 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8340 )
8341 .await
8342 .unwrap();
8343 svc.registry.set_polling("rust-works", "omni-dev", true);
8346 svc.start_pr_poller_with(
8347 Duration::from_millis(50),
8348 Duration::from_millis(10),
8349 fake.clone(),
8350 );
8351
8352 tokio::time::timeout(Duration::from_secs(30), async {
8353 loop {
8354 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8355 return;
8356 }
8357 tokio::time::sleep(Duration::from_millis(25)).await;
8358 }
8359 })
8360 .await
8361 .expect("poller should resolve a badge through the fake gh");
8362 let settled = svc.registry.change_generation();
8365 tokio::time::sleep(Duration::from_millis(150)).await;
8366 assert_eq!(
8367 svc.registry.change_generation(),
8368 settled,
8369 "an unchanged poll must not bump the change-notify"
8370 );
8371 svc.shutdown().await;
8372 }
8373
8374 #[tokio::test]
8375 #[allow(clippy::await_holding_lock)]
8377 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8378 let dir = tempfile::tempdir().unwrap();
8383 github_repo(dir.path());
8384 let bin_dir = tempfile::tempdir().unwrap();
8385 let (fake, _shim) = fake_gh(
8386 bin_dir.path(),
8387 r#"{"data":{"r0":{"b0":{
8388 "target":{"oid":"abc","statusCheckRollup":null},
8389 "associatedPullRequests":{"nodes":[]}
8390 }}}}"#,
8391 );
8392 let svc = WorktreesService::new();
8393 svc.handle(
8394 "register",
8395 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8396 )
8397 .await
8398 .unwrap();
8399 svc.registry.set_polling("rust-works", "omni-dev", true);
8402 svc.start_pr_poller_with(
8403 Duration::from_millis(50),
8404 Duration::from_millis(10),
8405 fake.clone(),
8406 );
8407
8408 tokio::time::timeout(Duration::from_secs(30), async {
8409 loop {
8410 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8411 return;
8412 }
8413 tokio::time::sleep(Duration::from_millis(25)).await;
8414 }
8415 })
8416 .await
8417 .expect("poller should resolve the negative through the fake gh");
8418
8419 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8421 assert_eq!(wt["pr_none"], json!(true));
8422 assert!(wt.get("pr").is_none(), "{wt:?}");
8423
8424 let settled = svc.registry.change_generation();
8426 tokio::time::sleep(Duration::from_millis(150)).await;
8427 assert_eq!(
8428 svc.registry.change_generation(),
8429 settled,
8430 "an unchanged negative must not bump the change-notify"
8431 );
8432 svc.shutdown().await;
8433 }
8434
8435 #[test]
8436 fn sync_indicator_formats_only_with_upstream() {
8437 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8438 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8439 assert_eq!(sync_indicator(None, None), None);
8440 assert_eq!(sync_indicator(Some(1), None), None);
8442 }
8443
8444 #[tokio::test]
8445 async fn list_enriches_entries_with_git_status() {
8446 let dir = tempfile::tempdir().unwrap();
8447 let repo = init_repo(dir.path());
8448 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8449 repo.set_head("refs/heads/main").unwrap();
8450
8451 let svc = WorktreesService::new();
8452 svc.handle(
8453 "register",
8454 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
8455 )
8456 .await
8457 .unwrap();
8458 let payload = svc.handle("list", Value::Null).await.unwrap();
8459 let windows = windows_of(&payload);
8460 assert_eq!(windows.len(), 1);
8461 assert_eq!(
8462 windows[0].get("branch").and_then(Value::as_str),
8463 Some("main")
8464 );
8465 assert!(windows[0].get("ahead").is_none());
8467 assert!(windows[0].get("behind").is_none());
8468 assert_eq!(
8470 windows[0].get("main_repo").and_then(Value::as_str),
8471 dir.path().file_name().and_then(|n| n.to_str())
8472 );
8473
8474 let plain = tempfile::tempdir().unwrap();
8476 svc.handle(
8477 "register",
8478 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
8479 )
8480 .await
8481 .unwrap();
8482 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
8483 let w2 = windows
8484 .iter()
8485 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
8486 .unwrap();
8487 assert!(w2.get("branch").is_none());
8488 assert!(w2.get("main_repo").is_none());
8489 }
8490
8491 #[test]
8492 fn window_label_prefers_git_branch_over_title() {
8493 let dir = tempfile::tempdir().unwrap();
8494 let repo = init_repo(dir.path());
8495 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8496 repo.set_head("refs/heads/main").unwrap();
8497 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
8498 let entry = WindowEntry {
8499 key: "k".to_string(),
8500 folders: vec![dir.path().to_path_buf()],
8501 repo: Some("companion-repo".to_string()),
8504 title: Some("ignored title".to_string()),
8505 pid: None,
8506 last_seen: Utc::now(),
8507 };
8508 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
8510 }
8511
8512 #[tokio::test]
8513 async fn list_includes_ahead_behind_for_tracking_branch() {
8514 let dir = tempfile::tempdir().unwrap();
8515 let _repo = diverging_repo(dir.path());
8516
8517 let svc = WorktreesService::new();
8518 svc.handle(
8519 "register",
8520 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
8521 )
8522 .await
8523 .unwrap();
8524 let payload = svc.handle("list", Value::Null).await.unwrap();
8525 let windows = windows_of(&payload);
8526 assert_eq!(
8528 windows[0].get("branch").and_then(Value::as_str),
8529 Some("main")
8530 );
8531 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
8532 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
8533 }
8534
8535 #[test]
8536 fn window_label_includes_sync_for_tracking_branch() {
8537 let dir = tempfile::tempdir().unwrap();
8538 let _repo = diverging_repo(dir.path());
8539 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
8540 let entry = WindowEntry {
8541 key: "k".to_string(),
8542 folders: vec![dir.path().to_path_buf()],
8543 repo: Some("companion-repo".to_string()),
8544 title: None,
8545 pid: None,
8546 last_seen: Utc::now(),
8547 };
8548 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
8550 }
8551
8552 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
8556 let commit = repo.find_commit(base).unwrap();
8557 repo.branch(branch, &commit, false).unwrap();
8558 let reference = repo
8559 .find_reference(&format!("refs/heads/{branch}"))
8560 .unwrap();
8561 let mut opts = git2::WorktreeAddOptions::new();
8562 opts.reference(Some(&reference));
8563 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
8564 }
8565
8566 #[test]
8567 fn git_status_marks_linked_worktree_and_names_parent_repo() {
8568 let main_dir = tempfile::tempdir().unwrap();
8569 let repo = init_repo(main_dir.path());
8570 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8571 repo.set_head("refs/heads/main").unwrap();
8572
8573 let wt_parent = tempfile::tempdir().unwrap();
8576 let wt_path = wt_parent.path().join("feature-wt");
8577 add_worktree(&repo, a, &wt_path, "feature");
8578
8579 let status = git_status(&wt_path);
8580 assert!(status.is_worktree);
8581 assert_eq!(status.branch.as_deref(), Some("feature"));
8582 assert_eq!(
8584 status.main_repo.as_deref(),
8585 main_dir.path().file_name().and_then(|n| n.to_str())
8586 );
8587
8588 let main_status = git_status(main_dir.path());
8590 assert!(!main_status.is_worktree);
8591 assert_eq!(main_status.main_repo, status.main_repo);
8592 }
8593
8594 #[test]
8595 fn window_label_marks_worktree_with_fork_glyph() {
8596 let main_dir = tempfile::tempdir().unwrap();
8597 let repo = init_repo(main_dir.path());
8598 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8599 repo.set_head("refs/heads/main").unwrap();
8600 let wt_parent = tempfile::tempdir().unwrap();
8601 let wt_path = wt_parent.path().join("feature-wt");
8602 add_worktree(&repo, a, &wt_path, "feature");
8603
8604 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
8605 let entry = WindowEntry {
8606 key: "k".to_string(),
8607 folders: vec![wt_path],
8608 repo: Some("feature-wt".to_string()),
8609 title: None,
8610 pid: None,
8611 last_seen: Utc::now(),
8612 };
8613 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
8616 }
8617
8618 #[test]
8619 fn main_repo_name_derives_from_common_dir() {
8620 assert_eq!(
8622 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
8623 Some("omni-dev")
8624 );
8625 assert_eq!(
8627 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
8628 Some("omni-dev")
8629 );
8630 assert_eq!(
8632 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
8633 Some("omni-dev")
8634 );
8635 assert_eq!(main_repo_name(Path::new("/.git")), None);
8637 }
8638
8639 fn repos_of(payload: &Value) -> Vec<Value> {
8644 payload
8645 .get("repos")
8646 .and_then(Value::as_array)
8647 .expect("repos array")
8648 .clone()
8649 }
8650
8651 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
8652 Some(GithubIdentity {
8653 owner: owner.to_string(),
8654 name: name.to_string(),
8655 })
8656 }
8657
8658 #[test]
8659 fn github_identity_parses_supported_forms() {
8660 assert_eq!(
8662 github_identity("https://github.com/rust-works/omni-dev.git"),
8663 github("rust-works", "omni-dev")
8664 );
8665 assert_eq!(
8666 github_identity("https://github.com/rust-works/omni-dev"),
8667 github("rust-works", "omni-dev")
8668 );
8669 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
8670 assert_eq!(
8672 github_identity("git@github.com:rust-works/omni-dev.git"),
8673 github("rust-works", "omni-dev")
8674 );
8675 assert_eq!(
8676 github_identity("ssh://git@github.com/o/r.git"),
8677 github("o", "r")
8678 );
8679 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
8680 assert_eq!(
8682 github_identity(" https://github.com/o/r/ "),
8683 github("o", "r")
8684 );
8685 }
8686
8687 #[test]
8688 fn github_identity_rejects_non_github_and_malformed() {
8689 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
8691 assert_eq!(github_identity("git@example.com:o/r.git"), None);
8692 assert_eq!(github_identity("https://github.com/onlyowner"), None);
8694 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
8695 assert_eq!(github_identity("https://github.com/"), None);
8696 assert_eq!(github_identity("not a url"), None);
8698 }
8699
8700 #[test]
8701 fn remote_github_identity_reads_origin_then_falls_back() {
8702 let dir = tempfile::tempdir().unwrap();
8703 let repo = init_repo(dir.path());
8704 assert_eq!(remote_github_identity(&repo), None);
8706 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
8708 assert_eq!(remote_github_identity(&repo), None);
8709 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
8711 .unwrap();
8712 assert_eq!(
8713 remote_github_identity(&repo),
8714 github("rust-works", "omni-dev")
8715 );
8716
8717 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
8720 .unwrap();
8721 repo.remote("upstream", "https://github.com/other/proj.git")
8722 .unwrap();
8723 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
8724 }
8725
8726 #[tokio::test]
8727 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
8728 let svc = WorktreesService::new();
8729 assert_eq!(
8731 svc.handle("tree", Value::Null).await.unwrap(),
8732 json!({ "repos": [], "show_closed": true })
8733 );
8734 let plain = tempfile::tempdir().unwrap();
8736 svc.handle(
8737 "register",
8738 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
8739 )
8740 .await
8741 .unwrap();
8742 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
8743 }
8744
8745 #[tokio::test]
8746 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
8747 let main_dir = tempfile::tempdir().unwrap();
8748 let repo = init_repo(main_dir.path());
8749 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8750 repo.set_head("refs/heads/main").unwrap();
8751 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
8753 .unwrap();
8754
8755 let wt_parent = tempfile::tempdir().unwrap();
8758 let wt_path = wt_parent.path().join("feature-wt");
8759 add_worktree(&repo, a, &wt_path, "feature");
8760
8761 let svc = WorktreesService::new();
8762 svc.handle(
8765 "register",
8766 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
8767 )
8768 .await
8769 .unwrap();
8770 svc.handle(
8771 "register",
8772 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
8773 )
8774 .await
8775 .unwrap();
8776
8777 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
8778 assert_eq!(
8779 repos.len(),
8780 1,
8781 "two worktrees of one repo dedupe: {repos:?}"
8782 );
8783 let repo0 = &repos[0];
8784 assert_eq!(
8786 repo0.get("main_repo").and_then(Value::as_str),
8787 main_dir.path().file_name().and_then(|n| n.to_str())
8788 );
8789 assert_eq!(
8790 repo0.pointer("/github/owner").and_then(Value::as_str),
8791 Some("rust-works")
8792 );
8793 assert_eq!(
8794 repo0.pointer("/github/name").and_then(Value::as_str),
8795 Some("omni-dev")
8796 );
8797 assert!(repo0.get("root").and_then(Value::as_str).is_some());
8798
8799 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
8800 assert_eq!(worktrees.len(), 2);
8801 let main_wt = &worktrees[0];
8803 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
8804 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
8805 assert_eq!(
8806 main_wt.get("window_key").and_then(Value::as_str),
8807 Some("wm")
8808 );
8809 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
8810 let linked = &worktrees[1];
8812 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
8813 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
8814 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
8815 assert_eq!(
8816 linked.get("branch").and_then(Value::as_str),
8817 Some("feature")
8818 );
8819 }
8820
8821 #[tokio::test]
8822 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
8823 let main_dir = tempfile::tempdir().unwrap();
8824 let repo = init_repo(main_dir.path());
8825 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8826 repo.set_head("refs/heads/main").unwrap();
8827 let wt_parent = tempfile::tempdir().unwrap();
8829 let wt_path = wt_parent.path().join("feature-wt");
8830 add_worktree(&repo, a, &wt_path, "feature");
8831
8832 let svc = WorktreesService::new();
8833 svc.handle(
8835 "register",
8836 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
8837 )
8838 .await
8839 .unwrap();
8840
8841 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
8842 assert_eq!(repos.len(), 1);
8843 assert!(repos[0].get("github").is_none(), "no remote → no github");
8844 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
8845 let linked = worktrees
8846 .iter()
8847 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
8848 .expect("the linked worktree");
8849 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
8851 assert!(linked.get("window_key").is_none());
8852 }
8853
8854 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
8860 let main_dir = tempfile::tempdir().unwrap();
8861 let repo = init_repo(main_dir.path());
8862 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
8863 repo.set_head("refs/heads/trunk").unwrap();
8864 let wt_parent = tempfile::tempdir().unwrap();
8865 let wt_path = wt_parent.path().join("feature-wt");
8866 add_worktree(&repo, a, &wt_path, "feature");
8867 (main_dir, wt_parent, wt_path)
8868 }
8869
8870 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
8874 {
8875 let main_dir = tempfile::tempdir().unwrap();
8876 let repo = init_repo(main_dir.path());
8877 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
8878 repo.set_head("refs/heads/trunk").unwrap();
8879 let wt_parent = tempfile::tempdir().unwrap();
8880 let first = wt_parent.path().join("first-wt");
8881 let second = wt_parent.path().join("second-wt");
8882 add_worktree(&repo, a, &first, "first");
8883 add_worktree(&repo, a, &second, "second");
8884 (main_dir, wt_parent, first, second)
8885 }
8886
8887 #[tokio::test]
8888 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
8889 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
8890 let svc = Arc::new(WorktreesService::new());
8891
8892 let close = |path: PathBuf| {
8902 let svc = svc.clone();
8903 async move {
8904 svc.handle(
8905 "close",
8906 json!({ "path": path, "remove": true, "confirmed": true }),
8907 )
8908 .await
8909 }
8910 };
8911 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
8912
8913 assert_eq!(a.unwrap(), json!({ "removed": true }));
8914 assert_eq!(b.unwrap(), json!({ "removed": true }));
8915 assert!(!first.exists());
8916 assert!(!second.exists());
8917 let repo = Repository::open(main_dir.path()).unwrap();
8920 assert!(repo.worktrees().unwrap().is_empty());
8921 }
8922
8923 fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
8930 let repo = init_repo(dir);
8931 let refname = format!("refs/heads/{branch}");
8932 let head = empty_commit(&repo, Some(&refname), &[], "A");
8933 repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
8934 .unwrap();
8935 repo.set_head(&refname).unwrap();
8936 let mut cfg = repo.config().unwrap();
8937 cfg.set_str("remote.origin.url", url).unwrap();
8938 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
8939 .unwrap();
8940 cfg.set_str(&format!("branch.{branch}.remote"), "origin")
8941 .unwrap();
8942 cfg.set_str(&format!("branch.{branch}.merge"), &refname)
8943 .unwrap();
8944 repo
8945 }
8946
8947 #[test]
8948 fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
8949 let dir = tempfile::tempdir().unwrap();
8950 let _repo = pushed_github_repo(
8951 dir.path(),
8952 "https://github.com/rust-works/omni-dev.git",
8953 "feature",
8954 );
8955 let ok = evaluate_local(dir.path()).expect("should be locally eligible");
8956 assert_eq!(
8957 ok.target,
8958 PrTarget {
8959 owner: "rust-works".into(),
8960 name: "omni-dev".into(),
8961 branch: "feature".into(),
8962 }
8963 );
8964 assert!(!ok.head_sha.is_empty());
8965 }
8966
8967 #[test]
8968 fn evaluate_local_skips_an_unborn_head() {
8969 let dir = tempfile::tempdir().unwrap();
8970 let _repo = init_repo(dir.path()); assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
8972 }
8973
8974 #[test]
8975 fn evaluate_local_skips_a_branch_with_no_upstream() {
8976 let dir = tempfile::tempdir().unwrap();
8977 let repo = init_repo(dir.path());
8978 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8979 repo.set_head("refs/heads/main").unwrap();
8980 repo.config()
8982 .unwrap()
8983 .set_str("remote.origin.url", "https://github.com/o/r.git")
8984 .unwrap();
8985 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
8986 }
8987
8988 #[test]
8989 fn evaluate_local_skips_unpushed_local_commits() {
8990 let dir = tempfile::tempdir().unwrap();
8991 let repo = init_repo(dir.path());
8992 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8993 let a_commit = repo.find_commit(a).unwrap();
8994 repo.reference("refs/remotes/origin/main", a, true, "o")
8996 .unwrap();
8997 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
8998 drop(a_commit);
8999 repo.set_head("refs/heads/main").unwrap();
9000 let mut cfg = repo.config().unwrap();
9001 cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9002 .unwrap();
9003 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9006 .unwrap();
9007 cfg.set_str("branch.main.remote", "origin").unwrap();
9008 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9009 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9010 }
9011
9012 #[test]
9013 fn evaluate_local_skips_a_detached_head() {
9014 let dir = tempfile::tempdir().unwrap();
9015 let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9016 let head = repo.head().unwrap().target().unwrap();
9017 repo.set_head_detached(head).unwrap();
9018 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9019 }
9020
9021 #[test]
9022 fn evaluate_local_skips_a_non_github_remote() {
9023 let dir = tempfile::tempdir().unwrap();
9024 let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9025 assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9026 }
9027
9028 #[test]
9029 fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9030 assert_eq!(
9034 evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9035 .unwrap_err()
9036 .kind,
9037 "not-a-repo"
9038 );
9039 }
9040
9041 #[test]
9042 fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9043 let req = MergeQueueRequest {
9046 paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9047 requester_key: Some("win-9".into()),
9048 check: true,
9049 confirmed: false,
9050 };
9051 let logs = capture_info(|| log_merge_check(&req, 1, 1));
9052 assert!(logs.contains("merge-queue check"), "{logs}");
9053 assert!(logs.contains("win-9"), "{logs}");
9054 assert!(logs.contains("requested=2"), "{logs}");
9055 assert!(logs.contains("eligible=1"), "{logs}");
9056 }
9057
9058 #[test]
9059 fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9060 let req = MergeQueueRequest {
9062 paths: vec![PathBuf::from("/a")],
9063 requester_key: None,
9064 check: false,
9065 confirmed: true,
9066 };
9067 let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9068 assert!(logs.contains("merge-queue enqueue"), "{logs}");
9069 assert!(logs.contains("queued=2"), "{logs}");
9070 assert!(logs.contains("failed=1"), "{logs}");
9071 }
9072
9073 #[test]
9074 fn evaluate_local_flags_dirty_then_untracked() {
9075 let main_dir = tempfile::tempdir().unwrap();
9077 let repo = init_repo(main_dir.path());
9078 let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9079 repo.set_head("refs/heads/main").unwrap();
9080 let wt_parent = tempfile::tempdir().unwrap();
9081 let wt_path = wt_parent.path().join("feature-wt");
9082 add_worktree(&repo, a, &wt_path, "feature");
9083
9084 let clean = evaluate_local(&wt_path).unwrap_err();
9086 assert_ne!(clean.kind, "dirty");
9087 assert_ne!(clean.kind, "untracked");
9088
9089 std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9091 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9092
9093 std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9095 std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9096 assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9097 }
9098
9099 #[test]
9100 fn is_conflicting_blocks_only_dirty_and_conflicting() {
9101 assert!(is_conflicting(Some("CONFLICTING")));
9102 assert!(is_conflicting(Some("DIRTY")));
9103 assert!(!is_conflicting(Some("CLEAN")));
9104 assert!(!is_conflicting(Some("BLOCKED")));
9105 assert!(!is_conflicting(Some("UNKNOWN")));
9106 assert!(!is_conflicting(None));
9107 }
9108
9109 #[test]
9110 fn merge_queue_request_parses_batch_and_phase_flags() {
9111 let req: MergeQueueRequest = serde_json::from_value(json!({
9112 "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9113 }))
9114 .unwrap();
9115 assert_eq!(req.paths.len(), 2);
9116 assert_eq!(req.requester_key.as_deref(), Some("w1"));
9117 assert!(req.confirmed);
9118 assert!(!req.check);
9119 let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9121 assert!(req.paths.is_empty());
9122 assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9123 }
9124
9125 #[test]
9126 fn queued_pr_omits_already_queued_when_false() {
9127 let v = serde_json::to_value(QueuedPr {
9128 path: "/a".into(),
9129 number: 5,
9130 already_queued: false,
9131 })
9132 .unwrap();
9133 assert!(v.get("already_queued").is_none(), "{v}");
9134 let v = serde_json::to_value(QueuedPr {
9135 path: "/a".into(),
9136 number: 5,
9137 already_queued: true,
9138 })
9139 .unwrap();
9140 assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9141 }
9142
9143 #[tokio::test]
9144 async fn merge_queue_check_on_empty_selection_reports_nothing() {
9145 let svc = WorktreesService::new();
9146 let reply = svc
9147 .handle("merge-queue", json!({ "paths": [], "check": true }))
9148 .await
9149 .unwrap();
9150 assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9151 }
9152
9153 #[tokio::test]
9154 async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9155 let dir = tempfile::tempdir().unwrap();
9158 let _repo = init_repo(dir.path());
9159 let svc = WorktreesService::new();
9160 let reply = svc
9161 .handle(
9162 "merge-queue",
9163 json!({ "paths": [dir.path()], "check": true }),
9164 )
9165 .await
9166 .unwrap();
9167 let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9168 assert_eq!(skipped.len(), 1);
9169 assert_eq!(
9170 skipped[0].get("kind").and_then(Value::as_str),
9171 Some("no-commits")
9172 );
9173 assert!(reply
9174 .get("eligible")
9175 .and_then(Value::as_array)
9176 .unwrap()
9177 .is_empty());
9178 }
9179
9180 fn ready_worktree() -> (tempfile::TempDir, String) {
9184 let dir = tempfile::tempdir().unwrap();
9185 let repo = pushed_github_repo(
9186 dir.path(),
9187 "https://github.com/rust-works/omni-dev.git",
9188 "feature",
9189 );
9190 let head = repo.head().unwrap().target().unwrap().to_string();
9191 (dir, head)
9192 }
9193
9194 fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9197 format!(
9198 r#"{{"data":{{"r0":{{"b0":{{
9199 "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9200 {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9201 ]}}}}}},
9202 "associatedPullRequests":{{"nodes":[{pr}]}}
9203 }}}}}}}}"#
9204 )
9205 }
9206
9207 fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9211 let ghdir = tempfile::tempdir().unwrap();
9212 let (bin, _shim) = fake_gh(ghdir.path(), reply);
9213 let paths = vec![head_dir.to_path_buf()];
9214 let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9215 if let Some(e) = eligible.first() {
9216 return Ok(e.number);
9217 }
9218 Err(skipped.remove(0).kind)
9219 }
9220
9221 #[test]
9222 fn evaluate_batch_marks_a_ready_pr_eligible() {
9223 let (dir, head) = ready_worktree();
9224 let pr = format!(
9225 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9226 );
9227 assert_eq!(
9228 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9229 Ok(9)
9230 );
9231 }
9232
9233 #[test]
9234 fn evaluate_batch_skips_a_draft_pr() {
9235 let (dir, head) = ready_worktree();
9236 let pr = format!(
9237 r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9238 );
9239 assert_eq!(
9240 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9241 Err("draft".to_string())
9242 );
9243 }
9244
9245 #[test]
9246 fn evaluate_batch_skips_a_conflicting_pr() {
9247 let (dir, head) = ready_worktree();
9248 let pr = format!(
9249 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9250 );
9251 assert_eq!(
9252 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9253 Err("conflicting".to_string())
9254 );
9255 }
9256
9257 #[test]
9258 fn evaluate_batch_skips_a_pr_with_failing_checks() {
9259 let (dir, head) = ready_worktree();
9260 let pr = format!(
9261 r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9262 );
9263 assert_eq!(
9264 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9265 Err("checks-failing".to_string())
9266 );
9267 }
9268
9269 #[test]
9270 fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9271 let (dir, head) = ready_worktree();
9272 let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9274 assert_eq!(
9275 network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9276 Err("stale".to_string())
9277 );
9278 }
9279
9280 #[test]
9281 fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9282 let (dir, head) = ready_worktree();
9283 let reply = format!(
9285 r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9286 );
9287 assert_eq!(
9288 network_gate_outcome(dir.path(), &reply),
9289 Err("no-pr".to_string())
9290 );
9291 }
9292
9293 #[test]
9294 fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9295 let eligible = vec![
9298 Eligible {
9299 path: PathBuf::from("/wt/a"),
9300 number: 1,
9301 url: "u".into(),
9302 branch: "a".into(),
9303 pr_id: "PR_A".into(),
9304 already_queued: true,
9305 },
9306 Eligible {
9307 path: PathBuf::from("/wt/b"),
9308 number: 2,
9309 url: "u".into(),
9310 branch: "b".into(),
9311 pr_id: "PR_B".into(),
9312 already_queued: false,
9313 },
9314 ];
9315 let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9316 assert_eq!(queued.len(), 1);
9317 assert_eq!(queued[0].number, 1);
9318 assert!(queued[0].already_queued);
9319 assert_eq!(failed.len(), 1);
9320 assert_eq!(failed[0].number, 2);
9321 }
9322
9323 #[test]
9324 fn enqueue_eligible_records_a_github_rejection_as_failed() {
9325 let ghdir = tempfile::tempdir().unwrap();
9329 let (bin, _shim) = fake_gh(
9330 ghdir.path(),
9331 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9332 );
9333 let eligible = vec![Eligible {
9334 path: PathBuf::from("/wt/a"),
9335 number: 7,
9336 url: "u".into(),
9337 branch: "a".into(),
9338 pr_id: "PR_A".into(),
9339 already_queued: false,
9340 }];
9341 let (queued, failed) = enqueue_eligible(&bin, eligible);
9342 assert!(queued.is_empty(), "{queued:?}");
9343 assert_eq!(failed.len(), 1);
9344 assert_eq!(failed[0].number, 7);
9345 assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9348 }
9349
9350 #[tokio::test]
9351 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9353 let (dir, head) = ready_worktree();
9356 let pr = format!(
9357 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9358 );
9359 let ghdir = tempfile::tempdir().unwrap();
9360 let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9361 let svc = WorktreesService::new();
9362 let reply = svc
9363 .merge_queue_with(
9364 MergeQueueRequest {
9365 paths: vec![dir.path().to_path_buf()],
9366 requester_key: None,
9367 check: true,
9368 confirmed: false,
9369 },
9370 bin,
9371 )
9372 .await
9373 .unwrap();
9374 let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9375 assert_eq!(eligible.len(), 1);
9376 assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9377 assert_eq!(
9378 eligible[0].get("branch").and_then(Value::as_str),
9379 Some("feature")
9380 );
9381 }
9382
9383 #[tokio::test]
9384 #[allow(clippy::await_holding_lock)] async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9386 let (dir, head) = ready_worktree();
9389 let pr = format!(
9390 r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9391 );
9392 let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9393 let ghdir = tempfile::tempdir().unwrap();
9394 let guard = shim_lock();
9395 let bin = ghdir.path().join("fake-gh");
9396 write_exec_script(
9397 &bin,
9398 &format!(
9399 "#!/bin/sh\ncase \"$*\" in\n *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n ;;\n *) cat <<'JSON'\n{resolve}\nJSON\n ;;\nesac\n",
9400 enqueue =
9401 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9402 ),
9403 );
9404 let svc = WorktreesService::new();
9405 let reply = svc
9406 .merge_queue_with(
9407 MergeQueueRequest {
9408 paths: vec![dir.path().to_path_buf()],
9409 requester_key: Some("w1".into()),
9410 check: false,
9411 confirmed: true,
9412 },
9413 bin,
9414 )
9415 .await
9416 .unwrap();
9417 drop(guard);
9418 let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9419 assert_eq!(queued.len(), 1, "{reply}");
9420 assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9421 assert!(reply
9422 .get("failed")
9423 .and_then(Value::as_array)
9424 .unwrap()
9425 .is_empty());
9426 }
9427
9428 #[tokio::test]
9429 async fn concurrent_closes_overlap_their_heartbeat_waits() {
9430 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9431 let svc = Arc::new(WorktreesService::new());
9432 for (key, path) in [("w2", &first), ("w3", &second)] {
9434 svc.handle("register", json!({ "key": key, "folders": [path] }))
9435 .await
9436 .unwrap();
9437 }
9438
9439 let spawn_close = |path: PathBuf| {
9440 let svc = svc.clone();
9441 tokio::spawn(async move {
9442 svc.handle(
9443 "close",
9444 json!({
9445 "path": path,
9446 "remove": true,
9447 "confirmed": true,
9448 "requester_key": "w1",
9449 }),
9450 )
9451 .await
9452 })
9453 };
9454 let a = spawn_close(first.clone());
9455 let b = spawn_close(second.clone());
9456
9457 for key in ["w2", "w3"] {
9464 let mut saw_close = false;
9465 for _ in 0..400 {
9466 let hb = svc
9467 .handle("heartbeat", json!({ "key": key }))
9468 .await
9469 .unwrap();
9470 if hb.get("close").and_then(Value::as_bool) == Some(true) {
9471 saw_close = true;
9472 break;
9473 }
9474 tokio::time::sleep(Duration::from_millis(5)).await;
9475 }
9476 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
9477 }
9478 assert!(
9479 !a.is_finished() && !b.is_finished(),
9480 "neither close can have finished: both windows are still registered"
9481 );
9482
9483 for key in ["w2", "w3"] {
9485 svc.handle("unregister", json!({ "key": key }))
9486 .await
9487 .unwrap();
9488 }
9489 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
9490 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
9491 assert!(!first.exists());
9492 assert!(!second.exists());
9493 }
9494
9495 #[tokio::test]
9496 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
9497 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
9498 let svc = WorktreesService::new();
9499 let report = svc
9502 .handle("close", json!({ "path": wt_path, "remove": true }))
9503 .await
9504 .unwrap();
9505 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
9506 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
9507 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
9508 assert!(report
9509 .get("risks")
9510 .and_then(Value::as_array)
9511 .unwrap()
9512 .is_empty());
9513 assert!(wt_path.exists());
9515 }
9516
9517 #[tokio::test]
9518 async fn close_removes_a_clean_linked_worktree() {
9519 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
9520 let svc = WorktreesService::new();
9521 let reply = svc
9522 .handle(
9523 "close",
9524 json!({ "path": wt_path, "remove": true, "confirmed": true }),
9525 )
9526 .await
9527 .unwrap();
9528 assert_eq!(reply, json!({ "removed": true }));
9529 assert!(
9530 !wt_path.exists(),
9531 "the worktree directory should be deleted"
9532 );
9533 }
9534
9535 #[derive(Clone, Default)]
9545 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
9546
9547 impl std::io::Write for CaptureWriter {
9548 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
9549 self.0.lock().unwrap().extend_from_slice(buf);
9550 Ok(buf.len())
9551 }
9552 fn flush(&mut self) -> std::io::Result<()> {
9553 Ok(())
9554 }
9555 }
9556
9557 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
9558 type Writer = Self;
9559 fn make_writer(&'a self) -> Self::Writer {
9560 self.clone()
9561 }
9562 }
9563
9564 fn capture_info(f: impl FnOnce()) -> String {
9567 let writer = CaptureWriter::default();
9568 let subscriber = tracing_subscriber::fmt()
9569 .with_max_level(tracing::Level::INFO)
9570 .with_ansi(false)
9571 .with_writer(writer.clone())
9572 .finish();
9573 tracing::subscriber::with_default(subscriber, f);
9574 let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
9575 logs
9576 }
9577
9578 fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
9582 RebaseRequest {
9583 paths,
9584 requester_key: None,
9585 check: false,
9586 confirmed: false,
9587 keep_conflicts: false,
9588 autostash: false,
9589 onto: None,
9590 }
9591 }
9592
9593 fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9601 let main_dir = tempfile::tempdir().unwrap();
9602 let repo = init_repo(main_dir.path());
9603 let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
9604 repo.set_head("refs/heads/main").unwrap();
9605 let wt_parent = tempfile::tempdir().unwrap();
9606 let wt_path = wt_parent.path().join("feature-wt");
9607 add_worktree(&repo, base, &wt_path, "feature");
9608 commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
9610 (main_dir, wt_parent, wt_path)
9611 }
9612
9613 #[tokio::test]
9614 async fn rebase_with_refuses_an_empty_selection() {
9615 let svc = WorktreesService::new();
9617 let err = svc
9618 .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
9619 .await
9620 .unwrap_err()
9621 .to_string();
9622 assert!(err.contains("at least one path"), "{err}");
9623 }
9624
9625 #[tokio::test]
9626 async fn rebase_with_phase_one_reports_without_rebasing() {
9627 let (_main, _parent, wt) = behind_worktree();
9628 let before = Repository::open(&wt).unwrap().head().unwrap().target();
9629
9630 let svc = WorktreesService::new();
9631 let reply = svc
9632 .rebase_with(
9633 RebaseRequest {
9634 check: true,
9635 onto: Some("main".into()),
9636 ..rebase_req(vec![wt.clone()])
9637 },
9638 crate::git::resolve_git_binary(),
9639 )
9640 .await
9641 .unwrap();
9642
9643 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9644 assert_eq!(worktrees.len(), 1, "{reply}");
9645 assert_eq!(
9646 worktrees[0].get("status").and_then(Value::as_str),
9647 Some("would-rebase"),
9648 "{reply}"
9649 );
9650 let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
9652 assert_eq!(fetches.len(), 1);
9653 assert_eq!(
9654 fetches[0].get("fetched").and_then(Value::as_bool),
9655 Some(false)
9656 );
9657 assert_eq!(
9658 Repository::open(&wt).unwrap().head().unwrap().target(),
9659 before,
9660 "phase 1 must not move the branch"
9661 );
9662 }
9663
9664 #[tokio::test]
9665 async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
9666 let (_main, _parent, wt) = behind_worktree();
9667 let svc = WorktreesService::new();
9668 let reply = svc
9669 .rebase_with(
9670 RebaseRequest {
9671 confirmed: true,
9672 onto: Some("main".into()),
9673 ..rebase_req(vec![wt.clone()])
9674 },
9675 crate::git::resolve_git_binary(),
9676 )
9677 .await
9678 .unwrap();
9679
9680 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9681 assert_eq!(
9682 worktrees[0].get("status").and_then(Value::as_str),
9683 Some("rebased"),
9684 "{reply}"
9685 );
9686 assert!(
9688 svc.registry.rebasing_paths().is_empty(),
9689 "the rebasing mark must be cleared after the execute"
9690 );
9691 }
9692
9693 #[tokio::test]
9694 async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
9695 let (_main, _parent, wt) = behind_worktree();
9699 std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
9700
9701 let svc = WorktreesService::new();
9702 let reply = svc
9703 .rebase_with(
9704 RebaseRequest {
9705 confirmed: true,
9706 onto: Some("main".into()),
9707 ..rebase_req(vec![wt.clone()])
9708 },
9709 crate::git::resolve_git_binary(),
9710 )
9711 .await
9712 .unwrap();
9713
9714 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9715 assert_eq!(
9716 worktrees[0].get("status").and_then(Value::as_str),
9717 Some("skipped"),
9718 "{reply}"
9719 );
9720 assert_eq!(
9721 worktrees[0].get("reason").and_then(Value::as_str),
9722 Some("dirty"),
9723 "{reply}"
9724 );
9725 }
9726
9727 #[tokio::test]
9728 async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
9729 let (_main, _parent, wt) = behind_worktree();
9735 std::fs::create_dir_all(wt.join(".git")).ok();
9738 let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
9739 std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
9740 std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
9741 assert_ne!(
9742 Repository::open(&wt).unwrap().state(),
9743 RepositoryState::Clean,
9744 "precondition: the worktree looks mid-rebase to git2"
9745 );
9746
9747 let svc = WorktreesService::new();
9748 let reply = svc
9749 .rebase_with(
9750 RebaseRequest {
9751 confirmed: true,
9752 onto: Some("main".into()),
9753 ..rebase_req(vec![wt.clone()])
9754 },
9755 crate::git::resolve_git_binary(),
9756 )
9757 .await
9758 .unwrap();
9759
9760 let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9761 assert_eq!(
9762 worktrees[0].get("reason").and_then(Value::as_str),
9763 Some("operation-in-progress"),
9764 "{reply}"
9765 );
9766 assert_ne!(
9768 Repository::open(&wt).unwrap().state(),
9769 RepositoryState::Clean
9770 );
9771 }
9772
9773 #[test]
9774 fn rebase_request_maps_onto_engine_options() {
9775 let req = RebaseRequest {
9776 keep_conflicts: true,
9777 autostash: true,
9778 onto: Some("origin/release".into()),
9779 ..rebase_req(vec![PathBuf::from("/wt")])
9780 };
9781 let opts = req.options(PathBuf::from("/custom/git"));
9782 assert!(opts.keep_conflicts && opts.autostash);
9783 assert_eq!(opts.onto.as_deref(), Some("origin/release"));
9784 assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
9785 assert!(!opts.dry_run);
9789 }
9790
9791 #[test]
9792 fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
9793 let req = RebaseRequest {
9794 requester_key: Some("win-3".into()),
9795 check: true,
9796 ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
9797 };
9798 let plan = worktree_rebase::Plan {
9799 fetches: vec![worktree_rebase::FetchOutcome {
9800 repo_root: PathBuf::from("/repo"),
9801 onto: "origin/main".into(),
9802 fetched: true,
9803 ok: false,
9804 detail: Some("host unreachable".into()),
9805 }],
9806 worktrees: vec![
9807 worktree_rebase::WorktreeOutcome {
9808 path: PathBuf::from("/a"),
9809 branch: Some("a".into()),
9810 onto: "origin/main".into(),
9811 result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
9812 },
9813 worktree_rebase::WorktreeOutcome {
9814 path: PathBuf::from("/b"),
9815 branch: Some("b".into()),
9816 onto: "origin/main".into(),
9817 result: worktree_rebase::RebaseResult::UpToDate,
9818 },
9819 ],
9820 };
9821 let logs = capture_info(|| log_rebase_check(&req, &plan));
9822 assert!(logs.contains("rebase check"), "{logs}");
9823 assert!(logs.contains("win-3"), "{logs}");
9824 assert!(logs.contains("requested=2"), "{logs}");
9825 assert!(logs.contains("pending=1"), "{logs}");
9826 assert!(logs.contains("failed_fetches=1"), "{logs}");
9827 }
9828
9829 #[test]
9830 fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
9831 let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
9833 let outcome = |result| worktree_rebase::WorktreeOutcome {
9834 path: PathBuf::from("/x"),
9835 branch: Some("x".into()),
9836 onto: "origin/main".into(),
9837 result,
9838 };
9839 let outcomes = vec![
9840 outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
9841 outcome(worktree_rebase::RebaseResult::Conflict {
9842 detail: "CONFLICT".into(),
9843 left_in_place: true,
9844 }),
9845 outcome(worktree_rebase::RebaseResult::Skipped {
9846 reason: worktree_rebase::SkipReason::Dirty,
9847 }),
9848 ];
9849 let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
9850 assert!(logs.contains("rebase execute"), "{logs}");
9851 assert!(logs.contains("rebased=1"), "{logs}");
9852 assert!(logs.contains("conflicts=1"), "{logs}");
9853 assert!(logs.contains("left_in_place=1"), "{logs}");
9854 assert!(logs.contains("skipped=1"), "{logs}");
9855 assert!(logs.contains(r#"requester="-""#), "{logs}");
9856 }
9857
9858 #[test]
9859 fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
9860 assert_eq!(operation_slug(RepositoryState::Clean), None);
9861 assert_eq!(
9862 operation_slug(RepositoryState::Rebase).as_deref(),
9863 Some("rebase")
9864 );
9865 assert_eq!(
9866 operation_slug(RepositoryState::RebaseMerge).as_deref(),
9867 Some("rebase"),
9868 "the merge-backend rebase is still just a rebase to the user"
9869 );
9870 assert_eq!(
9871 operation_slug(RepositoryState::RebaseInteractive).as_deref(),
9872 Some("rebase-interactive")
9873 );
9874 assert_eq!(
9875 operation_slug(RepositoryState::Merge).as_deref(),
9876 Some("merge")
9877 );
9878 assert_eq!(
9879 operation_slug(RepositoryState::CherryPickSequence).as_deref(),
9880 Some("cherry-pick")
9881 );
9882 assert_eq!(
9883 operation_slug(RepositoryState::RevertSequence).as_deref(),
9884 Some("revert")
9885 );
9886 assert_eq!(
9887 operation_slug(RepositoryState::Bisect).as_deref(),
9888 Some("bisect")
9889 );
9890 assert_eq!(
9891 operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
9892 Some("apply-mailbox")
9893 );
9894 }
9895
9896 #[test]
9897 fn git_status_omits_operation_for_a_clean_worktree() {
9898 let dir = tempfile::tempdir().unwrap();
9899 let _repo = diverging_repo(dir.path());
9900 assert_eq!(
9901 git_status(dir.path()).operation,
9902 None,
9903 "a clean worktree carries no operation, so the field stays off the wire"
9904 );
9905 }
9906
9907 #[test]
9908 fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
9909 let main_dir = tempfile::tempdir().unwrap();
9910 let repo = init_repo(main_dir.path());
9911 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9912 repo.set_head("refs/heads/main").unwrap();
9913 let path = canonical(main_dir.path());
9914
9915 let quiet = worktree_entry(&path, true, &HashMap::new(), &HashSet::new());
9916 assert!(!quiet.rebasing);
9917 let json = serde_json::to_value(&quiet).unwrap();
9919 assert!(json.get("rebasing").is_none(), "{json}");
9920 assert!(json.get("operation").is_none(), "{json}");
9921
9922 let busy = worktree_entry(
9923 &path,
9924 true,
9925 &HashMap::new(),
9926 &std::iter::once(path.clone()).collect(),
9927 );
9928 assert!(busy.rebasing, "the registry's transient mark rides through");
9929 assert_eq!(
9930 serde_json::to_value(&busy).unwrap()["rebasing"],
9931 serde_json::Value::Bool(true)
9932 );
9933 }
9934
9935 #[test]
9936 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
9937 assert_eq!(note_kinds(&[]), "-");
9938 assert_eq!(
9939 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
9940 "dirty,untracked"
9941 );
9942 }
9943
9944 #[test]
9945 fn is_self_close_true_only_when_requester_owns_an_open_window() {
9946 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
9947 assert!(is_self_close(Some("w1"), &windows));
9948 assert!(
9949 !is_self_close(Some("w3"), &windows),
9950 "requester owns no window"
9951 );
9952 assert!(!is_self_close(None, &windows), "no requester");
9953 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
9954 }
9955
9956 #[test]
9957 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
9958 let logs = capture_info(|| {
9959 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
9960 assert_eq!(reply, json!({ "removed": true }));
9961 });
9962 assert!(
9963 logs.contains("worktrees close: linked worktree pruned"),
9964 "a successful prune must log an INFO audit line, got: {logs}"
9965 );
9966 assert!(
9967 logs.contains("/wt/feature"),
9968 "the target path must ride the line, got: {logs}"
9969 );
9970 }
9971
9972 #[test]
9973 fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
9974 let logs = capture_info(|| {
9979 let reply =
9980 log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
9981 assert_eq!(reply, json!({ "removed": true }));
9982 });
9983 assert!(
9984 logs.contains("worktrees close: nothing to prune, worktree already removed"),
9985 "an already-gone close must log its own outcome, got: {logs}"
9986 );
9987 assert!(
9988 !logs.contains("linked worktree pruned"),
9989 "an already-gone close must not claim it pruned, got: {logs}"
9990 );
9991 }
9992
9993 #[test]
9994 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
9995 let logs = capture_info(|| {
9997 let err = log_close_error(
9998 Path::new("/wt/feature"),
9999 "safety check",
10000 anyhow!("not a git worktree"),
10001 );
10002 assert_eq!(
10003 err.to_string(),
10004 "not a git worktree",
10005 "err propagates unchanged"
10006 );
10007 });
10008 assert!(
10009 logs.contains("worktrees close: safety check failed"),
10010 "a failed phase must log an ERROR audit line, got: {logs}"
10011 );
10012 assert!(
10013 logs.contains("not a git worktree"),
10014 "the cause must ride the line, got: {logs}"
10015 );
10016 assert!(
10017 logs.contains("/wt/feature"),
10018 "the target path must ride the line, got: {logs}"
10019 );
10020 }
10021
10022 #[test]
10023 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10024 let logs = capture_info(|| {
10025 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10026 assert!(err.is_err(), "a prune failure must propagate");
10027 });
10028 assert!(
10029 logs.contains("worktrees close: worktree prune failed"),
10030 "a prune failure must log a WARN audit line, got: {logs}"
10031 );
10032 assert!(
10033 logs.contains("locked"),
10034 "the failure cause must ride the line, got: {logs}"
10035 );
10036 }
10037
10038 #[test]
10039 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10040 let git = GitSafety {
10041 is_main: false,
10042 removable: true,
10043 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10044 info: vec![],
10045 };
10046 let logs = capture_info(|| {
10047 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10048 });
10049 assert!(
10050 logs.contains("worktrees close: safety check"),
10051 "phase-1 must log a safety-check line, got: {logs}"
10052 );
10053 assert!(
10054 logs.contains("/wt/feature"),
10055 "the path must ride the line, got: {logs}"
10056 );
10057 assert!(
10058 logs.contains("window_key=\"win-42\""),
10059 "the owning window key must ride the line, got: {logs}"
10060 );
10061 assert!(logs.contains("removable=true"), "got: {logs}");
10062 assert!(logs.contains("is_main=false"), "got: {logs}");
10063 assert!(logs.contains("open=true"), "got: {logs}");
10064 assert!(
10065 logs.contains("risks=dirty,untracked"),
10066 "the blocking risk kinds must ride the line, got: {logs}"
10067 );
10068 }
10069
10070 #[test]
10071 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10072 let git = GitSafety {
10073 is_main: false,
10074 removable: true,
10075 risks: vec![],
10076 info: vec![],
10077 };
10078 let logs = capture_info(|| {
10079 log_safety_check(Path::new("/wt/feature"), None, &git, false);
10080 });
10081 assert!(
10082 logs.contains("window_key=\"-\""),
10083 "no owning window → dash, got: {logs}"
10084 );
10085 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
10086 }
10087
10088 #[test]
10089 fn log_executing_logs_the_routing_decision() {
10090 let logs = capture_info(|| {
10091 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
10092 });
10093 assert!(
10094 logs.contains("worktrees close: executing"),
10095 "phase-2 must log the execute routing, got: {logs}"
10096 );
10097 assert!(
10098 logs.contains("requester=\"win-7\""),
10099 "the requester key must ride the line, got: {logs}"
10100 );
10101 assert!(logs.contains("remove=true"), "got: {logs}");
10102 assert!(logs.contains("self_close=false"), "got: {logs}");
10103 assert!(logs.contains("cross_window=3"), "got: {logs}");
10104 }
10105
10106 #[test]
10107 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
10108 let logs = capture_info(|| {
10109 log_close_abort(
10110 Path::new("/wt/feature"),
10111 &anyhow!("window(s) did not close in time: win-9"),
10112 );
10113 });
10114 assert!(
10115 logs.contains("worktrees close: aborted"),
10116 "an abort must log a WARN audit line, got: {logs}"
10117 );
10118 assert!(
10119 logs.contains("/wt/feature"),
10120 "the path must ride the line, got: {logs}"
10121 );
10122 assert!(
10123 logs.contains("win-9"),
10124 "the still-open window must ride the line, got: {logs}"
10125 );
10126 }
10127
10128 #[test]
10129 fn log_window_closed_logs_the_no_removal_outcome() {
10130 let logs = capture_info(|| {
10131 log_window_closed(Path::new("/wt/feature"));
10132 });
10133 assert!(
10134 logs.contains("worktrees close: window closed, no removal"),
10135 "a remove:false close must log the no-removal outcome, got: {logs}"
10136 );
10137 assert!(
10138 logs.contains("/wt/feature"),
10139 "the path must ride the line, got: {logs}"
10140 );
10141 }
10142
10143 #[test]
10144 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
10145 let (main, _wtp, wt_path) = repo_with_linked_worktree();
10149 let admin = main.path().join(".git").join("worktrees").join("feature");
10150 assert!(admin.exists(), "admin metadata should exist before removal");
10151
10152 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
10153
10154 assert!(!wt_path.exists(), "the working directory should be gone");
10155 assert!(!admin.exists(), "the admin metadata should be pruned");
10156 let main_repo = Repository::open(main.path()).unwrap();
10157 assert_eq!(
10158 main_repo.worktrees().unwrap().len(),
10159 0,
10160 "git should no longer track the worktree"
10161 );
10162 }
10163
10164 #[test]
10165 fn remove_worktree_recovers_a_half_removed_orphan() {
10166 let (main, _wtp, wt_path) = repo_with_linked_worktree();
10171 let admin = main.path().join(".git").join("worktrees").join("feature");
10172 std::fs::remove_dir_all(&admin).unwrap();
10174 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
10175 assert!(
10176 Repository::open(&wt_path).is_err(),
10177 "the orphan should not open as a repo"
10178 );
10179
10180 assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
10181 assert!(
10182 !wt_path.exists(),
10183 "the leftover directory should be removed"
10184 );
10185 }
10186
10187 fn orphaned_admin_worktree() -> (
10195 tempfile::TempDir,
10196 PathBuf,
10197 tempfile::TempDir,
10198 PathBuf,
10199 PathBuf,
10200 ) {
10201 let main_dir = tempfile::tempdir().unwrap();
10202 let main_root = main_dir.path().canonicalize().unwrap();
10203 let repo = init_repo(&main_root);
10204 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10205 repo.set_head("refs/heads/trunk").unwrap();
10206 let wt_parent = tempfile::tempdir().unwrap();
10207 let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
10208 add_worktree(&repo, a, &wt_path, "feature");
10209 let admin = main_root.join(".git").join("worktrees").join("feature");
10210 assert!(admin.exists(), "admin metadata exists before the orphaning");
10211 std::fs::remove_dir_all(&wt_path).unwrap();
10213 (main_dir, main_root, wt_parent, wt_path, admin)
10214 }
10215
10216 fn window_on(folder: &Path) -> WindowEntry {
10217 WindowEntry {
10218 key: "w".to_string(),
10219 folders: vec![folder.to_path_buf()],
10220 repo: None,
10221 title: None,
10222 pid: None,
10223 last_seen: Utc::now(),
10224 }
10225 }
10226
10227 #[test]
10228 fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
10229 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10233
10234 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
10235
10236 assert_eq!(
10237 removed,
10238 Removal::Pruned,
10239 "the orphaned admin must be pruned"
10240 );
10241 assert!(!admin.exists(), "the admin metadata should be gone");
10242 let main_repo = Repository::open(&main_root).unwrap();
10243 assert!(
10244 main_repo.worktrees().unwrap().is_empty(),
10245 "git should no longer track the orphaned worktree"
10246 );
10247 }
10248
10249 #[test]
10250 fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
10251 let main_dir = tempfile::tempdir().unwrap();
10255 let main_root = main_dir.path().canonicalize().unwrap();
10256 let repo = init_repo(&main_root);
10257 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10258 repo.set_head("refs/heads/trunk").unwrap();
10259 std::fs::create_dir_all(main_root.join(".nested")).unwrap();
10261 let wt_path = main_root.join(".nested").join("feature-wt");
10262 add_worktree(&repo, a, &wt_path, "feature");
10263 let admin = main_root.join(".git").join("worktrees").join("feature");
10264 std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
10265
10266 let removed = remove_worktree(&wt_path, &[]).unwrap();
10267
10268 assert_eq!(removed, Removal::Pruned);
10269 assert!(!admin.exists(), "the admin metadata should be gone");
10270 }
10271
10272 #[test]
10273 fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
10274 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10278 std::fs::remove_dir_all(&admin).unwrap();
10280
10281 let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
10282
10283 assert_eq!(removed, Removal::AlreadyGone);
10284 }
10285
10286 #[test]
10287 fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
10288 let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
10289 let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
10292 assert!(
10293 roots.contains(&main_root),
10294 "the owning main repo must be a candidate, got: {roots:?}"
10295 );
10296 }
10297
10298 #[test]
10299 fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
10300 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10304 let junk = tempfile::tempdir().unwrap();
10305
10306 let removed =
10307 prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
10308
10309 assert_eq!(removed, Removal::Pruned);
10310 assert!(
10311 !admin.exists(),
10312 "the real owner must still prune the orphan"
10313 );
10314 }
10315
10316 #[test]
10317 fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
10318 let main_dir = tempfile::tempdir().unwrap();
10322 let main_root = main_dir.path().canonicalize().unwrap();
10323 let repo = init_repo(&main_root);
10324 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10325 repo.set_head("refs/heads/trunk").unwrap();
10326 let wt_parent = tempfile::tempdir().unwrap();
10327 let wt_root = wt_parent.path().canonicalize().unwrap();
10328 let orphan = wt_root.join("orphan-wt");
10329 let sibling = wt_root.join("sibling-wt");
10330 add_worktree(&repo, a, &orphan, "orphan");
10331 add_worktree(&repo, a, &sibling, "sibling");
10332 let admin = main_root.join(".git").join("worktrees").join("orphan");
10333 std::fs::remove_dir_all(&orphan).unwrap();
10334
10335 let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
10338
10339 assert_eq!(removed, Removal::Pruned);
10340 assert!(
10341 !admin.exists(),
10342 "the orphan's admin metadata must be pruned"
10343 );
10344 }
10345
10346 #[test]
10347 fn prune_orphaned_admin_refuses_a_locked_orphan() {
10348 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10352 let main_repo = Repository::open(&main_root).unwrap();
10353 let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
10354 main_repo
10355 .find_worktree(&name)
10356 .unwrap()
10357 .lock(Some("in use"))
10358 .unwrap();
10359
10360 let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
10361
10362 assert!(
10363 err.to_string().contains("locked"),
10364 "a locked orphan must be refused, got: {err:#}"
10365 );
10366 assert!(admin.exists(), "a refused prune must leave the admin entry");
10367 }
10368
10369 #[test]
10370 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
10371 let (main, _wtp, wt_path) = repo_with_linked_worktree();
10372 assert!(!is_orphaned_worktree(&wt_path));
10374 assert!(!is_orphaned_worktree(main.path()));
10376 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
10378 .unwrap();
10379 assert!(is_orphaned_worktree(&wt_path));
10380 }
10381
10382 #[test]
10383 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
10384 let tmp = tempfile::tempdir().unwrap();
10385 let missing = tmp.path().join("gone");
10386 assert!(remove_dir_all_retrying(&missing).is_ok());
10387 }
10388
10389 #[test]
10390 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
10391 use std::io::Error;
10392 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
10393 assert!(
10394 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
10395 "errno {errno} is the concurrent-writer race and must be retried"
10396 );
10397 }
10398 for errno in [
10401 nix::libc::EACCES,
10402 nix::libc::EPERM,
10403 nix::libc::EROFS,
10404 nix::libc::ENOTDIR,
10405 ] {
10406 assert!(
10407 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
10408 "errno {errno} is permanent and must not be retried"
10409 );
10410 }
10411 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
10413 }
10414
10415 #[test]
10416 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
10417 let tmp = tempfile::tempdir().unwrap();
10421 let file = tmp.path().join("not-a-directory");
10422 std::fs::write(&file, b"x").unwrap();
10423
10424 let mut attempts = 0;
10425 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
10426 attempts += 1;
10427 std::fs::remove_dir_all(&file)
10428 })
10429 .unwrap_err();
10430
10431 assert_eq!(attempts, 1, "a permanent error must not be retried");
10432 assert!(
10433 err.to_string()
10434 .contains("failed to remove worktree directory"),
10435 "unexpected error: {err:#}"
10436 );
10437 assert!(err.source().is_some(), "the io::Error cause is preserved");
10438 assert!(file.exists());
10439 }
10440
10441 #[test]
10442 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
10443 let tmp = tempfile::tempdir().unwrap();
10447 let mut attempts = 0;
10448 let backoff = [Duration::ZERO, Duration::ZERO];
10449 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
10450 attempts += 1;
10451 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
10452 })
10453 .unwrap_err();
10454
10455 assert_eq!(attempts, backoff.len() + 1);
10457 assert!(
10458 err.to_string()
10459 .contains("failed to remove worktree directory"),
10460 "unexpected error: {err:#}"
10461 );
10462 }
10463
10464 #[test]
10465 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
10466 let tmp = tempfile::tempdir().unwrap();
10469 let mut attempts = 0;
10470 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
10471 attempts += 1;
10472 if attempts < 3 {
10473 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
10474 } else {
10475 Ok(())
10476 }
10477 });
10478 assert!(result.is_ok(), "{result:?}");
10479 assert_eq!(attempts, 3);
10480 }
10481
10482 #[test]
10483 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
10484 let tmp = tempfile::tempdir().unwrap();
10487 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
10488 assert!(!is_orphaned_worktree(tmp.path()));
10489 }
10490
10491 #[test]
10492 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
10493 let tmp = tempfile::tempdir().unwrap();
10496 let plain = tmp.path().join("plain");
10497 std::fs::create_dir(&plain).unwrap();
10498
10499 let err = remove_worktree(&plain, &[]).unwrap_err();
10500
10501 assert!(
10502 err.to_string().contains("not a git worktree"),
10503 "unexpected error: {err:#}"
10504 );
10505 assert!(plain.exists(), "a non-worktree path must be left alone");
10506 }
10507
10508 #[test]
10509 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
10510 use std::sync::atomic::{AtomicBool, Ordering};
10516 use std::sync::Arc;
10517
10518 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10519 let nested = wt_path.join("target").join("nested");
10525 std::fs::create_dir_all(&nested).unwrap();
10526
10527 let stop = Arc::new(AtomicBool::new(false));
10528 let writer_stop = Arc::clone(&stop);
10529 let writer_dir = nested;
10530 let writer = std::thread::spawn(move || {
10531 let mut n = 0u64;
10532 let deadline = std::time::Instant::now() + Duration::from_millis(400);
10535 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
10536 let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
10541 n += 1;
10542 }
10543 });
10544
10545 let result = remove_worktree(&wt_path, &[]);
10546 stop.store(true, Ordering::Relaxed);
10547 writer.join().unwrap();
10548
10549 assert!(
10550 result.is_ok(),
10551 "removal should retry past the writer: {result:?}"
10552 );
10553 assert!(!wt_path.exists(), "the worktree directory should be gone");
10554 }
10555
10556 #[tokio::test]
10557 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
10558 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10559 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
10561
10562 let svc = WorktreesService::new();
10563 let report = svc
10564 .handle("close", json!({ "path": wt_path, "remove": true }))
10565 .await
10566 .unwrap();
10567 let risks = report.get("risks").and_then(Value::as_array).unwrap();
10568 assert!(
10569 risks
10570 .iter()
10571 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
10572 "expected an untracked risk: {report}"
10573 );
10574 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10576 assert!(wt_path.exists());
10578 }
10579
10580 #[tokio::test]
10581 async fn close_confirmed_removes_a_dirty_worktree() {
10582 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10583 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
10584 let svc = WorktreesService::new();
10585 let reply = svc
10587 .handle(
10588 "close",
10589 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10590 )
10591 .await
10592 .unwrap();
10593 assert_eq!(reply, json!({ "removed": true }));
10594 assert!(!wt_path.exists());
10595 }
10596
10597 #[tokio::test]
10598 async fn close_refuses_to_remove_the_main_working_tree() {
10599 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
10600 let svc = WorktreesService::new();
10601 let report = svc
10603 .handle("close", json!({ "path": main.path(), "remove": true }))
10604 .await
10605 .unwrap();
10606 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
10607 assert_eq!(
10608 report.get("removable").and_then(Value::as_bool),
10609 Some(false)
10610 );
10611 assert!(svc
10614 .handle(
10615 "close",
10616 json!({ "path": main.path(), "remove": true, "confirmed": true }),
10617 )
10618 .await
10619 .is_err());
10620 assert!(main.path().exists());
10621 }
10622
10623 #[tokio::test]
10624 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
10625 let main_dir = tempfile::tempdir().unwrap();
10629 let repo = init_repo(main_dir.path());
10630 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10631 repo.set_head("refs/heads/trunk").unwrap();
10632 let wt_parent = tempfile::tempdir().unwrap();
10633 let wt_path = wt_parent.path().join("main-wt");
10634 add_worktree(&repo, a, &wt_path, "main");
10635
10636 let svc = WorktreesService::new();
10637 let reply = svc
10638 .handle(
10639 "close",
10640 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10641 )
10642 .await
10643 .unwrap();
10644 assert_eq!(reply, json!({ "removed": true }));
10645 assert!(!wt_path.exists());
10646 assert!(
10648 repo.find_branch("main", git2::BranchType::Local).is_ok(),
10649 "the default branch must survive worktree removal"
10650 );
10651 }
10652
10653 #[tokio::test]
10654 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
10655 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10656 let svc = WorktreesService::new();
10657 svc.handle(
10659 "close",
10660 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10661 )
10662 .await
10663 .unwrap();
10664 let reply = svc
10667 .handle(
10668 "close",
10669 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10670 )
10671 .await
10672 .unwrap();
10673 assert_eq!(reply, json!({ "removed": true }));
10674 }
10675
10676 #[tokio::test]
10677 async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
10678 let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10683 let svc = WorktreesService::new();
10684 svc.handle(
10687 "register",
10688 register_payload("main-w", None, &main_root.display().to_string()),
10689 )
10690 .await
10691 .unwrap();
10692
10693 let before = svc.handle("tree", Value::Null).await.unwrap();
10695 let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
10696 assert_eq!(
10697 worktrees_before, 2,
10698 "the orphaned row is present before close"
10699 );
10700
10701 let reply = svc
10702 .handle(
10703 "close",
10704 json!({ "path": wt_path, "remove": true, "confirmed": true }),
10705 )
10706 .await
10707 .unwrap();
10708 assert_eq!(reply, json!({ "removed": true }));
10709
10710 assert!(!admin.exists(), "the admin metadata must be pruned");
10712 let after = svc.handle("tree", Value::Null).await.unwrap();
10713 let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
10714 assert_eq!(worktrees_after, 1, "only the main working tree remains");
10715 }
10716
10717 #[tokio::test]
10718 async fn close_safety_check_detects_detached_head_unreachable_commits() {
10719 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10720 let wt_repo = Repository::open(&wt_path).unwrap();
10723 let parent_oid = wt_repo.head().unwrap().target().unwrap();
10724 let parent = wt_repo.find_commit(parent_oid).unwrap();
10725 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
10726 wt_repo.set_head_detached(orphan).unwrap();
10727
10728 let svc = WorktreesService::new();
10729 let report = svc
10730 .handle("close", json!({ "path": wt_path, "remove": true }))
10731 .await
10732 .unwrap();
10733 let risks = report.get("risks").and_then(Value::as_array).unwrap();
10734 assert!(
10735 risks
10736 .iter()
10737 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
10738 "expected an unreachable-commits risk: {report}"
10739 );
10740 }
10741
10742 #[tokio::test]
10743 async fn close_self_close_removes_when_the_requester_owns_the_target() {
10744 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10745 let svc = WorktreesService::new();
10746 svc.handle(
10750 "register",
10751 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
10752 )
10753 .await
10754 .unwrap();
10755 let reply = svc
10756 .handle(
10757 "close",
10758 json!({
10759 "path": wt_path,
10760 "remove": true,
10761 "confirmed": true,
10762 "requester_key": "w1",
10763 }),
10764 )
10765 .await
10766 .unwrap();
10767 assert_eq!(reply, json!({ "removed": true }));
10768 assert!(!wt_path.exists());
10769 }
10770
10771 #[tokio::test]
10772 async fn close_safety_check_surfaces_the_owning_window() {
10773 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10774 let svc = WorktreesService::new();
10775 svc.handle(
10778 "register",
10779 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
10780 )
10781 .await
10782 .unwrap();
10783 let report = svc
10784 .handle("close", json!({ "path": wt_path, "remove": true }))
10785 .await
10786 .unwrap();
10787 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
10788 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
10789 assert_eq!(
10790 report.get("window_folder_count").and_then(Value::as_u64),
10791 Some(2)
10792 );
10793 }
10794
10795 #[tokio::test]
10796 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
10797 let svc = WorktreesService::new();
10798 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10799 .await
10800 .unwrap();
10801 assert_eq!(
10803 svc.handle("heartbeat", json!({ "key": "w1" }))
10804 .await
10805 .unwrap(),
10806 json!({ "known": true })
10807 );
10808 svc.registry.mark_close_pending("w1");
10810 assert_eq!(
10811 svc.handle("heartbeat", json!({ "key": "w1" }))
10812 .await
10813 .unwrap(),
10814 json!({ "known": true, "close": true })
10815 );
10816 assert_eq!(
10817 svc.handle("heartbeat", json!({ "key": "w1" }))
10818 .await
10819 .unwrap(),
10820 json!({ "known": true })
10821 );
10822 }
10823
10824 #[tokio::test]
10827 async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
10828 let svc = WorktreesService::new();
10829 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10830 .await
10831 .unwrap();
10832 assert_eq!(
10835 svc.handle("heartbeat", json!({ "key": "w1" }))
10836 .await
10837 .unwrap(),
10838 json!({ "known": true })
10839 );
10840 svc.registry.mark_reload_pending("w1");
10842 assert_eq!(
10843 svc.handle("heartbeat", json!({ "key": "w1" }))
10844 .await
10845 .unwrap(),
10846 json!({ "known": true, "reload": true })
10847 );
10848 assert_eq!(
10849 svc.handle("heartbeat", json!({ "key": "w1" }))
10850 .await
10851 .unwrap(),
10852 json!({ "known": true })
10853 );
10854 }
10855
10856 #[tokio::test]
10857 async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
10858 let svc = WorktreesService::new();
10859 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10860 .await
10861 .unwrap();
10862 svc.registry.mark_close_pending("w1");
10866 svc.registry.mark_reload_pending("w1");
10867 assert_eq!(
10868 svc.handle("heartbeat", json!({ "key": "w1" }))
10869 .await
10870 .unwrap(),
10871 json!({ "known": true, "close": true, "reload": true })
10872 );
10873 assert_eq!(
10874 svc.handle("heartbeat", json!({ "key": "w1" }))
10875 .await
10876 .unwrap(),
10877 json!({ "known": true })
10878 );
10879 }
10880
10881 #[tokio::test]
10882 async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
10883 let svc = WorktreesService::new();
10884 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10885 .await
10886 .unwrap();
10887 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
10888 .await
10889 .unwrap();
10890
10891 let reply = svc
10894 .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
10895 .await
10896 .unwrap();
10897 assert_eq!(
10898 reply,
10899 json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
10900 );
10901
10902 assert!(svc.registry.take_reload_pending("w1"));
10905 assert!(svc.registry.take_reload_pending("w2"));
10906 assert!(!svc.registry.take_reload_pending("ghost"));
10907 }
10908
10909 #[tokio::test]
10910 async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
10911 let svc = WorktreesService::new();
10912 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10913 .await
10914 .unwrap();
10915
10916 assert_eq!(
10919 svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
10920 .await
10921 .unwrap(),
10922 json!({ "requested": 1, "signalled": 1, "unknown": [] })
10923 );
10924
10925 assert_eq!(
10928 svc.handle("reload", json!({ "target_keys": [] }))
10929 .await
10930 .unwrap(),
10931 json!({ "requested": 0, "signalled": 0, "unknown": [] })
10932 );
10933 assert_eq!(
10934 svc.handle("reload", json!({})).await.unwrap(),
10935 json!({ "requested": 0, "signalled": 0, "unknown": [] })
10936 );
10937 }
10938
10939 #[tokio::test]
10940 async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
10941 let svc = WorktreesService::new();
10942 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10943 .await
10944 .unwrap();
10945 svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
10946 .await
10947 .unwrap();
10948
10949 svc.handle("reload", json!({ "target_keys": ["w2"] }))
10952 .await
10953 .unwrap();
10954 assert_eq!(
10955 svc.handle("heartbeat", json!({ "key": "w2" }))
10956 .await
10957 .unwrap(),
10958 json!({ "known": true, "reload": true })
10959 );
10960 assert_eq!(
10962 svc.handle("heartbeat", json!({ "key": "w1" }))
10963 .await
10964 .unwrap(),
10965 json!({ "known": true })
10966 );
10967 }
10968
10969 #[tokio::test]
10970 async fn reload_op_treats_an_unregistered_window_as_unknown() {
10971 let svc = WorktreesService::new();
10972 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10973 .await
10974 .unwrap();
10975 svc.handle("unregister", json!({ "key": "w1" }))
10976 .await
10977 .unwrap();
10978 assert_eq!(
10981 svc.handle("reload", json!({ "target_keys": ["w1"] }))
10982 .await
10983 .unwrap(),
10984 json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
10985 );
10986 }
10987
10988 #[tokio::test]
10989 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
10990 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10991 let svc = Arc::new(WorktreesService::new());
10992 svc.handle(
10994 "register",
10995 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
10996 )
10997 .await
10998 .unwrap();
10999
11000 let svc2 = svc.clone();
11003 let path = wt_path.clone();
11004 let close = tokio::spawn(async move {
11005 svc2.handle(
11006 "close",
11007 json!({
11008 "path": path,
11009 "remove": true,
11010 "confirmed": true,
11011 "requester_key": "w1",
11012 }),
11013 )
11014 .await
11015 });
11016
11017 let mut saw_close = false;
11020 for _ in 0..200 {
11021 let hb = svc
11022 .handle("heartbeat", json!({ "key": "w2" }))
11023 .await
11024 .unwrap();
11025 if hb.get("close").and_then(Value::as_bool) == Some(true) {
11026 saw_close = true;
11027 svc.handle("unregister", json!({ "key": "w2" }))
11028 .await
11029 .unwrap();
11030 break;
11031 }
11032 tokio::time::sleep(Duration::from_millis(5)).await;
11033 }
11034 assert!(saw_close, "w2 should have been told to close");
11035
11036 let reply = close.await.unwrap().unwrap();
11038 assert_eq!(reply, json!({ "removed": true }));
11039 assert!(!wt_path.exists());
11040 }
11041
11042 #[tokio::test]
11043 async fn await_windows_closed_times_out_when_a_window_never_closes() {
11044 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11045 let svc = WorktreesService::new();
11046 svc.handle(
11047 "register",
11048 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11049 )
11050 .await
11051 .unwrap();
11052 let err = await_windows_closed(
11055 &svc.registry,
11056 &wt_path,
11057 Some("w1"),
11058 Duration::from_millis(150),
11059 Duration::from_millis(25),
11060 )
11061 .await
11062 .unwrap_err();
11063 assert!(
11064 err.to_string().contains("w2"),
11065 "error names the window: {err}"
11066 );
11067 await_windows_closed(
11069 &svc.registry,
11070 &wt_path,
11071 Some("w2"),
11072 Duration::from_millis(150),
11073 Duration::from_millis(25),
11074 )
11075 .await
11076 .unwrap();
11077 }
11078
11079 #[tokio::test]
11080 async fn close_window_without_remove_replies_closed_and_never_deletes() {
11081 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11082 let svc = WorktreesService::new();
11083 let reply = svc
11085 .handle("close", json!({ "path": main.path(), "remove": false }))
11086 .await
11087 .unwrap();
11088 assert_eq!(reply, json!({ "closed": true }));
11089 assert!(main.path().exists());
11090 }
11091
11092 #[tokio::test]
11093 async fn close_safety_check_flags_modified_tracked_files() {
11094 let main_dir = tempfile::tempdir().unwrap();
11098 let repo = init_repo(main_dir.path());
11099 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
11100 repo.set_head("refs/heads/trunk").unwrap();
11101 let wt_parent = tempfile::tempdir().unwrap();
11102 let wt_path = wt_parent.path().join("feature-wt");
11103 add_worktree(&repo, a, &wt_path, "feature");
11104 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
11105
11106 let svc = WorktreesService::new();
11107 let report = svc
11108 .handle("close", json!({ "path": wt_path, "remove": true }))
11109 .await
11110 .unwrap();
11111 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11112 assert!(
11113 risks
11114 .iter()
11115 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
11116 "expected a dirty risk: {report}"
11117 );
11118 }
11119
11120 #[tokio::test]
11121 async fn close_safety_check_flags_an_in_progress_operation() {
11122 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11125 let wt_repo = Repository::open(&wt_path).unwrap();
11126 let head = wt_repo.head().unwrap().target().unwrap();
11127 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
11128 assert_ne!(wt_repo.state(), RepositoryState::Clean);
11129
11130 let svc = WorktreesService::new();
11131 let report = svc
11132 .handle("close", json!({ "path": wt_path, "remove": true }))
11133 .await
11134 .unwrap();
11135 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11136 assert!(
11137 risks
11138 .iter()
11139 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
11140 "expected an in-progress risk: {report}"
11141 );
11142 }
11143
11144 #[tokio::test]
11145 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
11146 let main_dir = tempfile::tempdir().unwrap();
11150 let repo = init_repo(main_dir.path());
11151 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11152 repo.set_head("refs/heads/trunk").unwrap();
11153 let a_commit = repo.find_commit(a).unwrap();
11154 repo.branch("feature", &a_commit, false).unwrap();
11155 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
11156 .unwrap();
11157 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
11159 drop(a_commit);
11160 let mut cfg = repo.config().unwrap();
11161 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
11162 .unwrap();
11163 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
11164 .unwrap();
11165 cfg.set_str("branch.feature.remote", "origin").unwrap();
11166 cfg.set_str("branch.feature.merge", "refs/heads/feature")
11167 .unwrap();
11168 let wt_parent = tempfile::tempdir().unwrap();
11171 let wt_path = wt_parent.path().join("feature-wt");
11172 let reference = repo.find_reference("refs/heads/feature").unwrap();
11173 let mut opts = git2::WorktreeAddOptions::new();
11174 opts.reference(Some(&reference));
11175 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
11176
11177 let svc = WorktreesService::new();
11178 let report = svc
11179 .handle("close", json!({ "path": wt_path, "remove": true }))
11180 .await
11181 .unwrap();
11182 let info = report.get("info").and_then(Value::as_array).unwrap();
11185 assert!(
11186 info.iter()
11187 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
11188 "expected an unpushed info note: {report}"
11189 );
11190 assert!(
11191 report
11192 .get("risks")
11193 .and_then(Value::as_array)
11194 .unwrap()
11195 .is_empty(),
11196 "unpushed commits alone must not block: {report}"
11197 );
11198 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11199 }
11200
11201 #[tokio::test]
11202 async fn close_safety_check_ignores_gitignored_files() {
11203 let main_dir = tempfile::tempdir().unwrap();
11207 let repo = init_repo(main_dir.path());
11208 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
11209 repo.set_head("refs/heads/trunk").unwrap();
11210 let wt_parent = tempfile::tempdir().unwrap();
11211 let wt_path = wt_parent.path().join("feature-wt");
11212 add_worktree(&repo, a, &wt_path, "feature");
11213 std::fs::create_dir(wt_path.join("build")).unwrap();
11214 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
11215
11216 let svc = WorktreesService::new();
11217 let report = svc
11218 .handle("close", json!({ "path": wt_path, "remove": true }))
11219 .await
11220 .unwrap();
11221 assert!(
11222 report
11223 .get("risks")
11224 .and_then(Value::as_array)
11225 .unwrap()
11226 .is_empty(),
11227 "a gitignored file must not create a risk: {report}"
11228 );
11229 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11230 }
11231
11232 #[tokio::test]
11233 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
11234 let svc = WorktreesService::new();
11237 let report = svc
11238 .handle(
11239 "close",
11240 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
11241 )
11242 .await
11243 .unwrap();
11244 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11245 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
11246 assert!(report
11247 .get("risks")
11248 .and_then(Value::as_array)
11249 .unwrap()
11250 .is_empty());
11251 let info = report.get("info").and_then(Value::as_array).unwrap();
11252 assert!(info
11253 .iter()
11254 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
11255 }
11256
11257 #[tokio::test]
11258 async fn close_phase1_errors_on_a_non_git_worktree_path() {
11259 let dir = tempfile::tempdir().unwrap();
11264 let svc = WorktreesService::new();
11265 let result = svc
11266 .handle("close", json!({ "path": dir.path(), "remove": true }))
11267 .await;
11268 assert!(
11269 result.is_err(),
11270 "a non-git-worktree target must error the safety check, got: {result:?}"
11271 );
11272 }
11273
11274 #[tokio::test]
11275 async fn close_refuses_a_locked_worktree() {
11276 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11279 let main_repo = Repository::open(main.path()).unwrap();
11280 main_repo
11281 .find_worktree("feature")
11282 .unwrap()
11283 .lock(Some("under test"))
11284 .unwrap();
11285
11286 let svc = WorktreesService::new();
11287 let err = svc
11288 .handle(
11289 "close",
11290 json!({ "path": wt_path, "remove": true, "confirmed": true }),
11291 )
11292 .await
11293 .unwrap_err();
11294 assert!(
11295 err.to_string().contains("locked"),
11296 "expected a locked error: {err}"
11297 );
11298 assert!(wt_path.exists(), "a locked worktree must not be removed");
11299 }
11300
11301 #[tokio::test]
11302 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
11303 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11307 let wt_repo = Repository::open(&wt_path).unwrap();
11308 let tip = wt_repo.head().unwrap().target().unwrap();
11311 wt_repo.set_head_detached(tip).unwrap();
11312 assert!(wt_repo.head_detached().unwrap());
11313
11314 let svc = WorktreesService::new();
11315 let report = svc
11316 .handle("close", json!({ "path": wt_path, "remove": true }))
11317 .await
11318 .unwrap();
11319 let risks = report.get("risks").and_then(Value::as_array).unwrap();
11320 assert!(
11321 !risks
11322 .iter()
11323 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11324 "a detached HEAD reachable from a branch must not be flagged: {report}"
11325 );
11326 }
11327
11328 #[test]
11329 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
11330 let (main, _wtp, wt_path) = repo_with_linked_worktree();
11331 let main_repo = Repository::open(main.path()).unwrap();
11332 assert_eq!(
11334 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
11335 "feature"
11336 );
11337 let err =
11340 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
11341 assert!(
11342 err.to_string().contains("not registered"),
11343 "expected a not-registered error: {err}"
11344 );
11345 }
11346
11347 #[test]
11348 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
11349 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11352 let repo = Repository::open(&wt_path).unwrap();
11353 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
11354 assert!(
11357 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
11358 "a corrupt index should make statuses() fail"
11359 );
11360 assert_eq!(count_dirty_untracked(&repo), (0, 0));
11361 }
11362}