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