omni_dev/daemon/services/worktrees.rs
1//! The worktrees daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`WorktreesRegistry`] under the
4//! daemon's lifecycle and exposes register/heartbeat/unregister/list/tree/open
5//! over the control socket, plus a tray submenu with a per-window "focus" action.
6//! The `open` op (#1266) focuses/opens an arbitrary worktree folder in VS Code
7//! through the **same** launcher path the tray uses, so a socket client (the
8//! companion's double-click) shares the tested guard and launcher resolution
9//! rather than duplicating them.
10//!
11//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
12//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
13//! ops, renders the menu/status, and drives the VS Code launcher. Like the
14//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
15//! secret persisted.
16//!
17//! The adapter also computes the **per-worktree git enrichment** (current
18//! branch, ahead/behind counts, and the parent repository a linked worktree
19//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
20//! of raw folder paths (ADR-0040). The engine stores only what the companion
21//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
22//! under the registry lock.
23//!
24//! The `tree` op (#1265) inverts the data model for the companion's tree view:
25//! from the open windows the adapter derives the **distinct repositories**, then
26//! enumerates **all** of each repo's worktrees (main working tree +
27//! [`Repository::worktrees`]), enriches each (reusing [`git_status`]), tags the
28//! GitHub identity of `origin`, and joins the open windows back on by
29//! canonicalized path. The open-window registry stays the liveness source;
30//! "is a window open on it?" becomes a per-worktree attribute. All of this is
31//! git disk I/O, so it runs on a blocking thread, never under the registry lock.
32
33use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use anyhow::{anyhow, bail, Context, Result};
41use chrono::{DateTime, Utc};
42
43use crate::github_rate_limit::{
44 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
45};
46use crate::pr_status::{PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget};
47use async_trait::async_trait;
48use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
49use serde::{Deserialize, Serialize};
50use serde_json::{json, Value};
51use tokio::sync::watch;
52use tokio::sync::Mutex as AsyncMutex;
53use tokio::task::JoinHandle;
54use tokio_util::sync::CancellationToken;
55
56use crate::daemon::service::{
57 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
58};
59use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
60
61/// The worktrees service name (the control-socket routing key).
62pub const SERVICE_NAME: &str = "worktrees";
63
64/// The tray submenu title.
65const SUBMENU_TITLE: &str = "Worktrees";
66
67/// Environment override for the VS Code launcher used by the "focus" tray
68/// action, for when the daemon runs under launchd with a minimal `PATH`.
69const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
70
71/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
72/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
73const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
74
75/// Default cadence at which the background task recomputes the tray menu snapshot
76/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
77/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
78/// the GUI thread (which would peg a core and stall shutdown — the #1186
79/// regression); the interval only governs how stale that cached branch/sync state
80/// may be when the menu is opened.
81///
82/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
83/// walk that the subscription-stream coalescing (#1303) never touched — it was
84/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
85/// menu open-latency unchanged (the cache still serves instantly).
86const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
87
88/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
89/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
90fn menu_refresh_interval() -> Duration {
91 crate::daemon::server::duration_secs_from_env(
92 ENV_MENU_REFRESH_INTERVAL,
93 DEFAULT_MENU_REFRESH_INTERVAL,
94 )
95}
96
97/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
98/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
99/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
100const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
101
102/// Default cadence for the PR badge poller while CI is in flight (#1337).
103///
104/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
105/// watching a run — which is exactly this situation. It is affordable because the
106/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
107/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
108/// while something is actually pending.
109const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
110
111/// The ceiling the poller backs off to once every badge is terminal.
112///
113/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
114/// The 30-minute figure is the cross-tool consensus for background PR polling
115/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
116/// defaults). The backoff exists for battery and wakeups rather than budget — at
117/// 1 point per poll the budget never binds.
118const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
119
120/// How long after fresh work (a push, or an added target) the poller holds its
121/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
122/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
123/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
124/// is watched more cheaply. See [`next_pr_poll_delay`].
125const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
126
127/// The ceiling the poller escalates to *while still pending* once
128/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
129/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
130/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
131/// at ~50 calls instead of ~120.
132const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
133
134/// The cadence floor the poller is held to while the shared GitHub budget is
135/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
136/// Five minutes makes the daemon's contribution to an already-strained budget
137/// negligible while still recovering promptly once the window resets — a pause in
138/// all but name. See [`budget_throttled_delay`].
139const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
140
141/// Environment override for [`pr_debounce_interval`] — the settle window the PR
142/// poller waits after the change-notify fires before snapshotting (whole seconds;
143/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
144const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
145
146/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
147///
148/// A VS Code restart unregisters then re-registers its windows one-by-one over
149/// several seconds, each bump waking the poller; a daemon restart re-registers the
150/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
151/// storm into **one** fetch on the final watch set instead of one per window.
152const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
153
154/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
155/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
156fn pr_poll_interval() -> Duration {
157 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
158}
159
160/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
161/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
162fn pr_debounce_interval() -> Duration {
163 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
164}
165
166/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
167/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
168/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
169const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
170
171/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
172/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
173/// the extension's transient badge fallback — from the daemon means N windows now
174/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
175const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
176
177/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
178/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
179/// `PullRequest` shape unchanged.
180const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
181
182/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
183/// (parity with the extension's `PR_LIST_LIMIT`).
184const OPEN_PR_LIST_LIMIT: &str = "100";
185
186/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
187/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
188fn open_pr_ttl() -> Duration {
189 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
190}
191
192/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
193/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
194/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
195const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
196
197/// Default cadence for the GitHub rate-limit monitor (#1375).
198///
199/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
200/// spends nothing against any budget — so unlike the PR poller this cadence has no
201/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
202/// keeps `daemon status` current enough to catch a slow drain.
203const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
204
205/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
206/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
207fn rate_limit_poll_interval() -> Duration {
208 crate::daemon::server::duration_secs_from_env(
209 ENV_RATE_LIMIT_POLL_INTERVAL,
210 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
211 )
212}
213
214/// A running background menu-refresh task and the token that stops it.
215struct RefreshTask {
216 /// Cancelled by `shutdown` to end the refresh loop.
217 token: CancellationToken,
218 /// The spawned loop, awaited on shutdown so it fully unwinds.
219 handle: JoinHandle<()>,
220}
221
222/// Whether this tick should spend a `gh` call.
223///
224/// The poller wakes far more often than it fetches — waking is a cached snapshot
225/// read, fetching is a subprocess and a network round trip. Two things justify the
226/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
227/// a push, invisible to the change-notify, so only looking finds it), or the
228/// **backoff elapsed** and it is simply time to look again.
229///
230/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
231/// worktree going away, a lease lapsing, or a TTL reap can never change any
232/// **surviving** badge, so `grew` is false and the poll skips — see
233/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
234///
235/// Pure so the policy is testable directly: from outside, the only evidence of it
236/// is *when* a subprocess runs, which a test cannot pin down without either flaking
237/// or passing for the wrong reason.
238fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
239 // `map_or(true, ..)` rather than `is_none_or`: the latter is stable only
240 // since 1.82 and this crate's MSRV is 1.80.
241 grew || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
242}
243
244/// Whether `next` holds a watch the poller has not already resolved for its
245/// current upstream — an **addition** (a new (repo, branch) target) or an
246/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
247/// reports). Either warrants asking GitHub *now*.
248///
249/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
250/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
251/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
252/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
253/// exactly the cached verdict, and the badge stays correctly stale through
254/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
255/// network call (#1389, fix 3).
256///
257/// Pure so the fetch trigger is testable without driving a live subprocess.
258fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
259 next.iter().any(|w| !prev.contains(w))
260}
261
262/// The next PR-poll delay.
263///
264/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
265/// — nothing is expected to change, so this is a slow liveness heartbeat. A failed
266/// poll passes `pending: false`, so a persistent failure backs off here rather
267/// than being retried hard.
268/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
269/// [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
270/// `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
271/// run used to pin `base` for its whole duration (360 calls/hour); escalating
272/// *within* pending caps that while a verdict arriving a cadence-tick late stays
273/// invisible in the tray. `since_moved` is time since fresh work was last seen (a
274/// push or an added target), or `None` when nothing has moved yet — treated as
275/// past the fast window so a stale-from-boot pending state does not pin `base`.
276///
277/// A pure function rather than copies inline, because the cadence is only
278/// observable from outside as timing, which a test cannot assert without flaking.
279fn next_pr_poll_delay(
280 current: Duration,
281 base: Duration,
282 pending: bool,
283 since_moved: Option<Duration>,
284) -> Duration {
285 if !pending {
286 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
287 }
288 match since_moved {
289 // Fresh work: watch it closely at `base` while it is likely to resolve.
290 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
291 // Still pending well after the move (a long CI run, or a zombie suite):
292 // escalate so the cadence stops burning, bounded below the terminal ceiling.
293 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
294 }
295}
296
297/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
298///
299/// The daemon is the **single** `gh` choke point for every open window, so this is
300/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
301/// any tracked resource is at/over
302/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
303/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
304/// the budget the whole machine shares — structurally, not just by convention. Below
305/// the threshold (or with no reading yet) the delay is returned unchanged.
306///
307/// Pure so the throttle is testable without a live rate-limit poll.
308fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
309 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
310 delay.max(BUDGET_THROTTLE_INTERVAL)
311 } else {
312 delay
313 }
314}
315
316/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
317/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
318/// threshold upward since the previous reading (or is already over on the first
319/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
320/// per crossing rather than every poll while usage stays high.
321///
322/// Pure so the policy is testable without driving a live poll.
323fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
324 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
325 // Per-resource so a *different* resource crossing (while another recovers) is
326 // still caught — `graphql` dropping below while `core` climbs over would look
327 // unchanged to a whole-snapshot `over_warn` comparison.
328 [
329 (prev.and_then(|p| p.graphql), next.graphql),
330 (prev.and_then(|p| p.core), next.core),
331 (prev.and_then(|p| p.search), next.search),
332 ]
333 .into_iter()
334 .any(|(before, after)| over(after) && !over(before))
335}
336
337/// A running background PR-badge poll task and the token that stops it.
338struct PollerTask {
339 /// Cancelled by `shutdown` to end the poll loop.
340 token: CancellationToken,
341 /// The spawned loop, awaited on shutdown so it fully unwinds.
342 handle: JoinHandle<()>,
343}
344
345/// One thing the PR poller watches: a badge target and the commit its upstream
346/// points at.
347///
348/// The upstream OID is what makes a **push** observable to the poller. A window
349/// opening bumps the registry's change-notify, but nothing notifies the daemon
350/// when you push — so the poller compares this against the previous tick's and
351/// treats an added target or a moved upstream as "go and ask now" (see
352/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
353/// very thing that starts the CI run a badge reports, so the upstream must be here.
354///
355/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
356/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
357/// call, and the badge stays correctly stale through
358/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
359/// comparison, no network — until the branch is actually pushed. Equality is thus
360/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
361#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
362struct PrWatch {
363 /// The (repo, branch) to resolve a badge for.
364 target: PrTarget,
365 /// That branch's upstream tip, or `None` when it tracks no upstream.
366 upstream_sha: Option<String>,
367}
368
369/// Extracts what the poller watches — the badge targets and their local heads and
370/// upstream tips — from a `tree` snapshot.
371///
372/// Reading them back off the snapshot — rather than walking git again — means the
373/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
374/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
375/// out of its way to remove. Only GitHub repos with a branch contribute; the result
376/// is sorted and deduped so N worktrees of one repo on one branch ask once.
377fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
378 let mut out = Vec::new();
379 for repo in snapshot
380 .get("repos")
381 .and_then(Value::as_array)
382 .into_iter()
383 .flatten()
384 {
385 // The zero-`gh` guarantee (#1376): a repo the user has not enabled
386 // contributes no watch, so the poll's `gh api graphql` never mentions it.
387 // The snapshot this reads is already `stamp_polling`-stamped, so this one
388 // check is the single filter point — default-off means an absent flag skips.
389 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
390 continue;
391 }
392 let Some(github) = repo.get("github") else {
393 continue;
394 };
395 let (Some(owner), Some(name)) = (
396 github.get("owner").and_then(Value::as_str),
397 github.get("name").and_then(Value::as_str),
398 ) else {
399 continue;
400 };
401 for wt in repo
402 .get("worktrees")
403 .and_then(Value::as_array)
404 .into_iter()
405 .flatten()
406 {
407 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
408 out.push(PrWatch {
409 upstream_sha: wt
410 .get("upstream_sha")
411 .and_then(Value::as_str)
412 .map(str::to_string),
413 target: PrTarget {
414 owner: owner.to_string(),
415 name: name.to_string(),
416 branch: branch.to_string(),
417 },
418 });
419 }
420 }
421 }
422 out.sort();
423 out.dedup();
424 out
425}
426
427/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
428/// without the heads.
429#[cfg(test)]
430fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
431 pr_watch_from_snapshot(snapshot)
432 .into_iter()
433 .map(|w| w.target)
434 .collect()
435}
436
437/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
438pub struct WorktreesService {
439 /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
440 /// the background menu-refresh task can read it off the main thread.
441 registry: Arc<WorktreesRegistry>,
442 /// The most recent tray menu snapshot, recomputed off the main thread by
443 /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
444 /// of this so it never blocks on git enrichment. `None` until the first
445 /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
446 /// which case `menu()` falls back to a one-off inline compute.
447 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
448 /// The background refresh task, once started (`None` in tests / no runtime).
449 refresh: Mutex<Option<RefreshTask>>,
450 /// PR badges resolved by the background poller and read by the tree snapshot
451 /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
452 /// share the one cache. Empty until the first poll lands — and always empty
453 /// when no poller runs (unit tests), in which case the tree simply carries no
454 /// `pr` field, exactly as a pre-#1337 daemon did.
455 pr_cache: Arc<PrStatusCache>,
456 /// The background PR-badge poll task, once started (`None` in tests / no
457 /// runtime).
458 poller: Mutex<Option<PollerTask>>,
459 /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
460 /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
461 /// poll task, the tray refresh, and the daemon's registry share the one cache.
462 /// Empty until the first poll lands; the daemon hands a clone to the registry
463 /// so `status` can report machine-wide GitHub budget usage.
464 ///
465 /// [`rate-limit poller`]: Self::start_rate_limit_poller
466 rate_limit_cache: Arc<RateLimitCache>,
467 /// The background rate-limit poll task, once started (`None` in tests / no
468 /// runtime).
469 rate_limit_poller: Mutex<Option<PollerTask>>,
470 /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
471 /// through, so N open windows perform **one** `build_tree` per tick instead
472 /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
473 /// one cache. The one-shot `tree` op deliberately bypasses it and computes
474 /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
475 tree_cache: Arc<TreeSnapshotCache>,
476 /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
477 ///
478 /// The extension fans a multi-select delete out into one `close` op per
479 /// target, so two executes can reach the prune at once. Their heartbeat waits
480 /// overlap freely — that is the point — but the prunes themselves should not:
481 /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
482 /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
483 /// read a directory a sibling is midway through removing from, and `git2`
484 /// promises nothing about that. Precautionary rather than a fix for an
485 /// observed corruption — the window is narrow enough that it has not been
486 /// reproduced — but serializing costs nothing measurable (the prune is a
487 /// directory delete; the wait it follows is seconds) and keeps the fan-out
488 /// safe at the source rather than relying on every caller to stay sequential.
489 ///
490 /// A `tokio` mutex rather than a `std` one: it is held across the
491 /// `spawn_blocking` join, which is an `.await`.
492 prune_lock: tokio::sync::Mutex<()>,
493 /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
494 /// choice survives a daemon restart. `None` disables persistence entirely —
495 /// the default from [`new`](Self::new), which keeps the bare service cheap
496 /// and I/O-free for unit tests; the daemon wires it via
497 /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
498 /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
499 /// briefly and never held across an `.await`.
500 polling_prefs_path: Mutex<Option<PathBuf>>,
501 /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
502 /// survive a daemon restart and the poller can skip its immediate re-poll when
503 /// they are still fresh. `None` disables persistence — the default from
504 /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
505 /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
506 /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
507 pr_cache_path: Mutex<Option<PathBuf>>,
508 /// The warm-start state restored from the persisted cache (#1389, fix 4),
509 /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
510 /// spawns. `None` on a cold start (no file, or persistence disabled), in which
511 /// case the poller does its normal first fetch.
512 pr_warm_start: Mutex<Option<PrWarmStart>>,
513 /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
514 /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
515 /// dedupe to one counted `gh` per repo instead of one per window. Behind an
516 /// `Arc` for parity with the other caches.
517 open_pr_cache: Arc<OpenPrCache>,
518}
519
520impl WorktreesService {
521 /// Creates the service with an empty registry. Cheap — no I/O and no task;
522 /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
523 /// off-thread menu caching, while tests use the bare service (menu computed
524 /// inline on demand).
525 #[must_use]
526 pub fn new() -> Self {
527 let registry = Arc::new(WorktreesRegistry::new());
528 let pr_cache = Arc::new(PrStatusCache::new());
529 Self {
530 registry: registry.clone(),
531 menu_cache: Arc::new(Mutex::new(None)),
532 refresh: Mutex::new(None),
533 pr_cache: pr_cache.clone(),
534 poller: Mutex::new(None),
535 rate_limit_cache: Arc::new(RateLimitCache::new()),
536 rate_limit_poller: Mutex::new(None),
537 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
538 prune_lock: tokio::sync::Mutex::new(()),
539 polling_prefs_path: Mutex::new(None),
540 pr_cache_path: Mutex::new(None),
541 pr_warm_start: Mutex::new(None),
542 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
543 }
544 }
545
546 /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
547 /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
548 /// back to it (#1376). Called once by the daemon at startup, before any window
549 /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
550 /// bump. Best-effort throughout: a missing file is the first-run default (no
551 /// repos enabled), and a corrupt/unreadable one is logged and treated as
552 /// empty rather than wedging the service — the user simply re-enables. The
553 /// path is stored regardless, so the next change rewrites a clean file.
554 pub fn load_polling_prefs(&self, path: PathBuf) {
555 match std::fs::read(&path) {
556 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
557 Ok(prefs) => self
558 .registry
559 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
560 Err(err) => tracing::warn!(
561 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
562 path.display()
563 ),
564 },
565 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
566 Err(err) => tracing::warn!(
567 "could not read worktrees polling prefs at {}: {err:#}",
568 path.display()
569 ),
570 }
571 *self
572 .polling_prefs_path
573 .lock()
574 .unwrap_or_else(PoisonError::into_inner) = Some(path);
575 }
576
577 /// Writes the current enable set to the `0600` prefs file, if persistence is
578 /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
579 /// Best-effort: a write failure is logged at WARN and swallowed, since the
580 /// in-memory set is authoritative for the running daemon — the user's toggle
581 /// still took effect, it just would not survive a restart. A no-op (returns
582 /// early) in unit tests, which never configure a path.
583 fn persist_polling_prefs(&self) {
584 let Some(path) = self
585 .polling_prefs_path
586 .lock()
587 .unwrap_or_else(PoisonError::into_inner)
588 .clone()
589 else {
590 return;
591 };
592 let prefs = PollingPrefs {
593 enabled: self
594 .registry
595 .polling_snapshot()
596 .into_iter()
597 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
598 .collect(),
599 };
600 if let Err(err) = write_polling_prefs(&path, &prefs) {
601 tracing::warn!(
602 "could not persist worktrees polling prefs to {}: {err:#}",
603 path.display()
604 );
605 }
606 }
607
608 /// Seeds the resolved PR-badge cache from the persisted `0600` file and
609 /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
610 /// once by the daemon at startup, before any window subscribes and before the
611 /// poller spawns, so restored badges render on the first tree snapshot and the
612 /// poller can skip its immediate re-poll for verdicts still fresh.
613 ///
614 /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
615 /// contract): a missing file is the cold-start default, and a corrupt/unreadable
616 /// one is logged and treated as empty — the poller simply re-resolves. The path
617 /// is stored regardless, so the next poll rewrites a clean file. Restores both
618 /// the badges (into [`pr_cache`](Self::pr_cache)) and the
619 /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
620 pub fn load_pr_cache(&self, path: PathBuf) {
621 match std::fs::read(&path) {
622 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
623 Ok(prefs) => {
624 self.pr_cache.seed(
625 prefs
626 .entries
627 .into_iter()
628 .map(|e| (e.target, e.resolution.into_resolution())),
629 );
630 // A warm start needs both a watch set to compare against and a
631 // poll time to age it; without `polled_at` the file is too old a
632 // shape to trust, so treat it as a cold start (badges still
633 // render, the poller just re-polls immediately).
634 if let Some(polled_at) = prefs.polled_at {
635 let watched = prefs
636 .watched
637 .into_iter()
638 .map(|w| PrWatch {
639 target: w.target,
640 upstream_sha: w.upstream_sha,
641 })
642 .collect();
643 *self
644 .pr_warm_start
645 .lock()
646 .unwrap_or_else(PoisonError::into_inner) =
647 Some(PrWarmStart { watched, polled_at });
648 }
649 }
650 // Bind the path so it is formatted whenever the branch runs — not
651 // only when a WARN subscriber is installed — so coverage sees it
652 // (the `let summary = …` pattern the rate-limit warn uses).
653 Err(err) => {
654 let at = path.display();
655 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
656 }
657 },
658 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
659 Err(err) => {
660 let at = path.display();
661 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
662 }
663 }
664 *self
665 .pr_cache_path
666 .lock()
667 .unwrap_or_else(PoisonError::into_inner) = Some(path);
668 }
669
670 /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
671 /// served from the shared TTL cache when fresh, else **one** counted `gh pr
672 /// list`. The `gh` runs on a blocking thread (never an async worker), routed
673 /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
674 /// point so the call is still counted exactly once — the constraint the whole
675 /// of #1389 preserves. The result is forwarded to the extension verbatim.
676 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
677 // The `gh` binary is resolved once here (the env read is process-stable),
678 // then handed to the seam below — the poller's "bin as a param" pattern, so
679 // a test injects a stub without mutating the process environment (#1030).
680 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
681 .await
682 }
683
684 /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
685 /// the cache against a stub without touching the environment.
686 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
687 let key = format!("{owner}/{name}");
688 if let Some(prs) = self.open_pr_cache.fresh(&key) {
689 return Ok(prs);
690 }
691 let slug = key.clone();
692 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
693 .await
694 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
695 self.open_pr_cache.store(key, prs.clone());
696 Ok(prs)
697 }
698
699 /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
700 /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
701 /// for the built-in `status` op to read.
702 #[must_use]
703 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
704 self.rate_limit_cache.clone()
705 }
706
707 /// Starts the background task that recomputes the tray menu snapshot every
708 /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
709 /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
710 /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
711 /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
712 /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
713 /// keep computing the menu inline.
714 pub fn start_menu_refresh(&self) {
715 if tokio::runtime::Handle::try_current().is_err() {
716 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
717 return;
718 }
719 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
720 if guard.is_some() {
721 return;
722 }
723 let token = CancellationToken::new();
724 let loop_token = token.clone();
725 let registry = self.registry.clone();
726 let cache = self.menu_cache.clone();
727 let rate_limit_cache = self.rate_limit_cache.clone();
728 // Resolved once at spawn: the interval is process-stable env config, and
729 // re-reading it every loop would be wasted work.
730 let interval = menu_refresh_interval();
731 let handle = tokio::spawn(async move {
732 loop {
733 // Snapshot the registry (a cheap lock), then build the menu —
734 // which opens repos and parses git config — on a blocking thread,
735 // never on this async worker or the tray's main thread.
736 let entries = registry.list();
737 // The rate-limit reading (a cheap lock, `Copy`) prepends a status
738 // line; read it here and hand it to the blocking build (#1375).
739 let rate_limit = rate_limit_cache.get();
740 if let Ok(items) = tokio::task::spawn_blocking(move || {
741 menu_items_for(&entries, rate_limit.as_ref())
742 })
743 .await
744 {
745 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
746 }
747 tokio::select! {
748 () = loop_token.cancelled() => break,
749 () = tokio::time::sleep(interval) => {}
750 }
751 }
752 });
753 *guard = Some(RefreshTask { token, handle });
754 }
755
756 /// Starts the background task that keeps PR check badges fresh (#1337).
757 ///
758 /// This is the half of the badge nothing else can do. Badges used to be
759 /// resolved extension-side on repo-expand, so they were recomputed only when a
760 /// repo node's children were rebuilt — and the streamed snapshot carries
761 /// worktree topology, not CI. While CI ran and no window opened or closed,
762 /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
763 ///
764 /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
765 /// (cost 1, independent of repo/worktree/window count), writes the cache the
766 /// tree snapshot reads, and bumps the registry's change-notify **only when a
767 /// verdict actually moved** — so the server's diff pushes to every open window
768 /// exactly when CI state changes, and never otherwise.
769 ///
770 /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
771 /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
772 /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
773 /// terminal; it polls nothing at all while no window is registered.
774 ///
775 /// It spends a `gh` call only when the watch set **grows** — a target added or
776 /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
777 /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
778 /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
779 /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
780 /// 4), and the cadence is capped when the shared GitHub budget is strained
781 /// (#1389 fix 6).
782 ///
783 /// Idempotent, and a no-op outside a tokio runtime (mirroring
784 /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
785 /// heartbeat), so unit tests build a bare service that never spawns `gh`.
786 pub fn start_pr_poller(&self) {
787 // Resolved once at spawn: process-stable env config (the menu-refresh
788 // precedent), never re-read per poll.
789 self.start_pr_poller_with(
790 pr_poll_interval(),
791 pr_debounce_interval(),
792 crate::pr_status::resolve_gh_binary(),
793 );
794 }
795
796 /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
797 /// debounce settle window, and `gh` binary, so tests drive the loop at
798 /// millisecond speed against a stub **without mutating the process
799 /// environment** — one global env var cannot serve two parallel tests pointing
800 /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
801 /// Snowflake heartbeat's "interval via config, not env" rule.
802 ///
803 /// Reads the rate-limit cache, the persistence path, and the warm-start state
804 /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
805 /// the original two-cadence seam.
806 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
807 if tokio::runtime::Handle::try_current().is_err() {
808 tracing::debug!("no tokio runtime; worktrees PR poller not started");
809 return;
810 }
811 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
812 if guard.is_some() {
813 return;
814 }
815 let token = CancellationToken::new();
816 let loop_token = token.clone();
817 let registry = self.registry.clone();
818 let tree_cache = self.tree_cache.clone();
819 let pr_cache = self.pr_cache.clone();
820 // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
821 // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
822 // the loop once and must not be reused by a later restart of the poller.
823 let rate_limit_cache = self.rate_limit_cache.clone();
824 let pr_cache_path = self
825 .pr_cache_path
826 .lock()
827 .unwrap_or_else(PoisonError::into_inner)
828 .clone();
829 let warm_start = self
830 .pr_warm_start
831 .lock()
832 .unwrap_or_else(PoisonError::into_inner)
833 .take();
834 // Captured here, before the task's first sleep, so a window that registers
835 // while the loop is starting still wakes it rather than being missed.
836 let mut changes = self.registry.subscribe_changes();
837 let handle = tokio::spawn(async move {
838 // Two independent cadences. The loop *wakes* every `base` — cheap: a read
839 // of the coalescing snapshot cache, no subprocess, no network. It only
840 // *asks GitHub* when there is reason to: the watch set grew (a target
841 // added, or an upstream pushed), or the backoff has elapsed.
842 //
843 // They have to be separate because the two things that should trigger a
844 // fetch arrive by different routes. A window opening bumps the registry's
845 // change-notify, but **a push does not** — nothing in the daemon is
846 // notified when you `git push`. The only way to notice is to look, so the
847 // loop looks often and cheaply, and pays only when something grew.
848 let mut backoff = base;
849 // Warm start (#1389, fix 4): resume what the previous daemon last
850 // resolved and when, so a restart within the backoff window skips the
851 // immediate re-poll for verdicts already restored into `pr_cache`.
852 // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
853 // reboot (monotonic epoch reset) or a future timestamp collapses to
854 // "never polled", which just re-polls — the safe direction.
855 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
856 match warm_start {
857 Some(ws) => {
858 let elapsed = (Utc::now() - ws.polled_at)
859 .to_std()
860 .unwrap_or(Duration::ZERO);
861 (Some(ws.watched), Instant::now().checked_sub(elapsed))
862 }
863 None => (None, None),
864 };
865 // When fresh work (a push or an added target) was last seen, so the
866 // pending cadence can escalate once it goes quiet (#1389, fix 5).
867 let mut moved_at: Option<Instant> = None;
868 'poll: loop {
869 // Wait first: at startup no window has registered yet, and the
870 // first snapshot would be empty anyway.
871 tokio::select! {
872 () = loop_token.cancelled() => break,
873 () = tokio::time::sleep(base) => {}
874 // A window opened or closed — look now rather than at the next
875 // tick, but debounce first.
876 result = changes.changed() => {
877 // Unreachable today: this task owns an `Arc` of the registry
878 // that holds the sender, so it cannot be dropped while we are
879 // here. Kept anyway because the alternative is worse — a
880 // closed channel makes `changed()` return `Ready` forever, so
881 // ignoring the error would spin this loop at full speed,
882 // re-snapshotting and re-running `gh` every iteration.
883 if result.is_err() {
884 break;
885 }
886 // Debounce (#1389, fix 2): a VS Code restart unregisters then
887 // re-registers its windows one-by-one over several seconds,
888 // each bump waking us; a daemon restart re-registers the same
889 // way. Wait for `debounce` of quiet before snapshotting so the
890 // whole storm collapses to **one** fetch on the final watch
891 // set. Bounded by an overall deadline so a steady drip of
892 // changes cannot postpone the poll forever.
893 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
894 loop {
895 tokio::select! {
896 () = loop_token.cancelled() => break 'poll,
897 () = tokio::time::sleep(debounce) => break,
898 r = changes.changed() => {
899 if r.is_err() {
900 break 'poll;
901 }
902 if Instant::now() >= overall_deadline {
903 break;
904 }
905 }
906 }
907 }
908 }
909 }
910 // Off the coalescing snapshot cache, so this reuses the tick's
911 // `build_tree` rather than walking git a second time.
912 let snapshot = tree_cache.snapshot().await;
913 let watch = pr_watch_from_snapshot(&snapshot);
914 if watch.is_empty() {
915 // No windows, or nothing on GitHub: ask nothing, and forget any
916 // backoff so the next tree starts fresh. But **keep** `watched`
917 // (#1389, fix 4): a VS Code restart momentarily empties the watch
918 // mid-storm, and nulling it here would make the re-registered set
919 // look brand-new and re-fetch. A genuinely gone tree simply has
920 // nothing to compare against on the next non-empty tick.
921 backoff = base;
922 last_poll = None;
923 moved_at = None;
924 continue;
925 }
926 // Did the watched set **grow** (an addition, or an upstream pushed)?
927 // A pure removal is not a reason to fetch (#1389, fix 1); a local
928 // commit is not either (#1389, fix 3, `PrWatch` carries no head).
929 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
930 // Prune verdicts for targets that vanished, so a closed worktree's
931 // badge does not linger in the cache (#1389, fix 1) — local, no
932 // network, and correct whether or not this tick goes on to fetch.
933 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
934 pr_cache.retain_targets(&keep);
935 // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
936 // choke point, so throttling here is the one place a machine-wide cap
937 // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
938 // immediate `grew`, so no runaway in this class can drain the shared
939 // budget — structurally, not by convention.
940 let rate_limit = rate_limit_cache.get();
941 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
942 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
943 let trigger = grew && !over_budget;
944 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
945 // Not fetching this tick. Advance `watched` only for a pure shrink
946 // or a quiet identical tick — an addition/push (`grew`) must stay
947 // unresolved so it is still fetched once the cadence or budget
948 // allows, rather than being silently consumed here.
949 if !grew {
950 watched = Some(watch);
951 }
952 continue;
953 }
954 if grew {
955 // Fresh work: watch it closely and restart the escalation clock
956 // rather than serving out a backoff earned while it was quiet.
957 backoff = base;
958 moved_at = Some(Instant::now());
959 }
960 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
961 // `gh` is a blocking subprocess: never on an async worker. A join
962 // failure (the task panicked, or the runtime is going down) folds
963 // into the same error channel as a `gh` failure — both mean "no
964 // badges this round", and neither deserves its own handling.
965 let bin = gh_bin.clone();
966 let resolved = tokio::task::spawn_blocking(move || {
967 crate::pr_status::resolve_with_budget(&bin, &targets)
968 })
969 .await
970 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
971 // Best-effort decoration: a missing/unauthenticated `gh`, a network
972 // blip, or a rate limit must never sink the tree. A failed poll
973 // leaves the last good resolutions in place — badges *and* explicit
974 // negatives, and it mints no new negatives (#1370) — rather than
975 // blanking every row, and is not "pending", so it backs off rather
976 // than hammers.
977 let (pending, resolved_ok) = match resolved {
978 Ok((resolutions, budget)) => {
979 // Fold the free budget reading this poll carried into the
980 // shared cache (#1389, fix 8): the graphql figure stays fresh
981 // whenever polling is active, which lets the standalone
982 // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
983 // reveals the real per-call point price.
984 if let Some(b) = budget {
985 tracing::debug!(
986 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
987 b.cost,
988 b.used,
989 b.limit,
990 b.remaining
991 );
992 rate_limit_cache.observe_graphql(RateLimitResource::new(
993 b.used,
994 b.limit,
995 b.remaining,
996 b.reset,
997 ));
998 }
999 // Bump only on a real change, or the server's diff-and-drop
1000 // is defeated and every window re-renders on every poll.
1001 if pr_cache.replace(resolutions) {
1002 registry.bump();
1003 }
1004 (pr_cache.any_pending(), true)
1005 }
1006 Err(err) => {
1007 tracing::debug!("PR badge poll failed: {err:#}");
1008 (false, false)
1009 }
1010 };
1011 last_poll = Some(Instant::now());
1012 // Record what this verdict was about, so the *next* tick can tell a
1013 // genuine change from a quiet tree.
1014 watched = Some(watch);
1015 let since_moved = moved_at.map(|at| at.elapsed());
1016 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1017 // Persist the fresh verdicts (#1389, fix 4) so the next restart
1018 // serves them and can skip its immediate re-poll. Only on a real
1019 // resolution — a failed poll must not advance the persisted poll time
1020 // past the last *good* one. Best-effort; a write failure costs at
1021 // most one extra poll after the next restart.
1022 if resolved_ok {
1023 if let Some(path) = &pr_cache_path {
1024 persist_pr_cache(
1025 path,
1026 &pr_cache,
1027 watched.as_deref().unwrap_or(&[]),
1028 Utc::now(),
1029 );
1030 }
1031 }
1032 }
1033 });
1034 *guard = Some(PollerTask { token, handle });
1035 }
1036
1037 /// Starts the background task that keeps the GitHub API rate-limit reading
1038 /// fresh (#1375).
1039 ///
1040 /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1041 /// budget as every other tool sharing the user's token; when that drains, `gh`
1042 /// rate-limits machine-wide with no warning until commands start failing. This
1043 /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1044 /// project verified) as **exempt**, spending nothing against any budget — so
1045 /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1046 /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1047 ///
1048 /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1049 /// backoff and no window-gating, because the endpoint is free and a current
1050 /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1051 /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1052 /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1053 /// bare service that never spawns `gh`.
1054 pub fn start_rate_limit_poller(&self) {
1055 // Both resolved once at spawn: process-stable env config, never re-read per
1056 // poll (the PR-poller precedent).
1057 self.start_rate_limit_poller_with(
1058 rate_limit_poll_interval(),
1059 crate::pr_status::resolve_gh_binary(),
1060 );
1061 }
1062
1063 /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1064 /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1065 /// against a stub **without mutating the process environment** (the
1066 /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1067 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1068 if tokio::runtime::Handle::try_current().is_err() {
1069 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1070 return;
1071 }
1072 let mut guard = self
1073 .rate_limit_poller
1074 .lock()
1075 .unwrap_or_else(PoisonError::into_inner);
1076 if guard.is_some() {
1077 return;
1078 }
1079 let token = CancellationToken::new();
1080 let loop_token = token.clone();
1081 let cache = self.rate_limit_cache.clone();
1082 let registry = self.registry.clone();
1083 let handle = tokio::spawn(async move {
1084 // Remembers the previous reading so a WARN fires only on the *rising*
1085 // edge across the threshold, not every poll while usage stays high.
1086 let mut prev: Option<RateLimitSnapshot> = None;
1087 loop {
1088 // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1089 // no window registered and no polling lease active — has nothing to
1090 // watch, so it spends no `/rate_limit` subprocess and lets the last
1091 // reading stand. The read is free against the budget, but the
1092 // wakeups are not; and while polling *is* active the graphql figure
1093 // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1094 // standalone poll is only topping up `core`/`search`.
1095 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1096 // Poll first, so `status` has a reading soon after a window
1097 // appears rather than one interval later. `gh` is a blocking
1098 // subprocess: never on an async worker. A join failure folds into
1099 // the same channel as a `gh` failure — both mean "no fresh
1100 // reading this round".
1101 let bin = gh_bin.clone();
1102 let resolved =
1103 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1104 .await
1105 .unwrap_or_else(|err| {
1106 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1107 });
1108 match resolved {
1109 Ok(snap) => {
1110 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1111 // Bound to a local (rather than inlined into the
1112 // macro) so it is computed whenever the branch is
1113 // taken, not only when a WARN-level subscriber is
1114 // installed — `tracing` skips evaluating macro args
1115 // otherwise.
1116 let summary = snap.summary_line();
1117 tracing::warn!(
1118 "GitHub API rate limit high: {summary} (querying \
1119 /rate_limit is free; the daemon's gh usage is not)"
1120 );
1121 }
1122 // Update the cache only; deliberately no `registry.bump()`
1123 // — the rate limit is not tree topology, and bumping would
1124 // re-push an unchanged tree to every window. The tray
1125 // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1126 cache.replace(snap);
1127 prev = Some(snap);
1128 }
1129 // Best-effort decoration: a missing/unauthenticated `gh` or a
1130 // network blip leaves the last good reading in place rather
1131 // than blanking the line, and never affects the budget (the
1132 // read is free).
1133 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1134 }
1135 }
1136 tokio::select! {
1137 () = loop_token.cancelled() => break,
1138 () = tokio::time::sleep(interval) => {}
1139 }
1140 }
1141 });
1142 *guard = Some(PollerTask { token, handle });
1143 }
1144
1145 /// Handles the `close` op: close a worktree's window and, for a **linked**
1146 /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1147 ///
1148 /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1149 /// [`git_safety`] check returning the risks of deleting, so the extension can
1150 /// show a modal confirm only when something would actually be lost.
1151 /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1152 /// the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1153 /// worktree. The main working tree is refused defensively.
1154 ///
1155 /// Cross-window signalling (another window has the target open) is a
1156 /// fast-follow: this core handles the **no-window** and **self-close**
1157 /// (`requester_key == target_key`) cases, and errors clearly when another
1158 /// window owns the target so the destructive path is never taken blind.
1159 async fn close(&self, req: CloseRequest) -> Result<Value> {
1160 // Which live windows currently have the target open. The canonical-path
1161 // compare is disk I/O, so run it (with the safety check below) on a
1162 // blocking thread, never under the registry lock or on the async worker.
1163 let entries = self.registry.list();
1164 let scan_path = req.path.clone();
1165 let open_windows =
1166 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1167 .await
1168 .unwrap_or_default();
1169 let open = !open_windows.is_empty();
1170 let window_key = open_windows.first().map(|(k, _)| k.clone());
1171 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1172
1173 // Phase 1: the safety check runs only for a delete request awaiting
1174 // confirmation. A "Close Window" (remove:false) never inspects git and
1175 // has nothing to confirm, so it skips straight to execute.
1176 if req.remove && !req.confirmed {
1177 let path = req.path.clone();
1178 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1179 .await
1180 .map_err(|e| anyhow!("safety check task panicked: {e}"))??;
1181 return Ok(serde_json::to_value(SafetyReport {
1182 removable: git.removable,
1183 is_main: git.is_main,
1184 open,
1185 window_key,
1186 window_folder_count,
1187 risks: git.risks,
1188 info: git.info,
1189 })
1190 .unwrap_or_else(|_| json!({})));
1191 }
1192
1193 // Phase 2: execute. Signal every owning window *other than the
1194 // requester* (which closes itself on our `ok:true` reply, avoiding the
1195 // ext-host-dies-mid-op race) and wait for each to unregister before
1196 // touching the worktree. The directive reaches a cross-window target via
1197 // its heartbeat reply — the only channel the daemon has to a window it
1198 // can reply to but never call.
1199 let others: Vec<String> = open_windows
1200 .iter()
1201 .map(|(k, _)| k.clone())
1202 .filter(|k| req.requester_key.as_deref() != Some(k))
1203 .collect();
1204 for key in &others {
1205 self.registry.mark_close_pending(key);
1206 }
1207 if !others.is_empty() {
1208 await_windows_closed(
1209 &self.registry,
1210 &req.path,
1211 req.requester_key.as_deref(),
1212 CLOSE_WAIT_TIMEOUT,
1213 CLOSE_WAIT_POLL,
1214 )
1215 .await?;
1216 }
1217
1218 if req.remove {
1219 let path = req.path.clone();
1220 // Taken *after* the wait above, so concurrent executes still overlap
1221 // their heartbeat waits (#1359) and only the prune itself serializes.
1222 // Load-bearing placement, not incidental: hoisting this above
1223 // `await_windows_closed` would restack the waits and undo the whole
1224 // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1225 let _guard = self.prune_lock.lock().await;
1226 tokio::task::spawn_blocking(move || remove_worktree(&path))
1227 .await
1228 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))??;
1229 Ok(json!({ "removed": true }))
1230 } else {
1231 // "Close Window" with no owning window is a no-op success; a
1232 // self-close replies and the extension closes its own window.
1233 Ok(json!({ "closed": true }))
1234 }
1235 }
1236}
1237
1238impl Default for WorktreesService {
1239 fn default() -> Self {
1240 Self::new()
1241 }
1242}
1243
1244#[async_trait]
1245impl DaemonService for WorktreesService {
1246 fn name(&self) -> &'static str {
1247 SERVICE_NAME
1248 }
1249
1250 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1251 match op {
1252 "register" => {
1253 let req: RegisterRequest =
1254 serde_json::from_value(payload).context("invalid `register` payload")?;
1255 if req.key.trim().is_empty() {
1256 bail!("`register` requires a non-empty `key`");
1257 }
1258 self.registry.register(req);
1259 Ok(json!({ "ok": true }))
1260 }
1261 "heartbeat" => {
1262 let key = require_str(&payload, "key", "heartbeat")?;
1263 let known = self.registry.heartbeat(key);
1264 // A pending close directive (#1277) rides the reply as an
1265 // additive `close` field, taken-and-cleared here so it fires
1266 // exactly once. Omitted when false to keep older windows — which
1267 // read only `known` — byte-identical on the wire.
1268 let mut reply = json!({ "known": known });
1269 if self.registry.take_close_pending(key) {
1270 reply["close"] = Value::Bool(true);
1271 }
1272 Ok(reply)
1273 }
1274 "unregister" => {
1275 let key = require_str(&payload, "key", "unregister")?;
1276 Ok(json!({ "removed": self.registry.unregister(key) }))
1277 }
1278 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1279 "tree" => {
1280 // The same `{ repos, show_closed }` snapshot the `subscribe`
1281 // stream pushes, so a one-shot `tree` fetch and the live stream
1282 // agree byte-for-byte (the git enumeration runs off-lock on a
1283 // blocking thread inside the helper). Computed fresh here — the
1284 // `tree` op is a rare manual refresh, deliberately bypassing the
1285 // stream's coalescing cache so it never returns a stale view
1286 // (#1303).
1287 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1288 }
1289 "ahead-behind" => {
1290 // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1291 // snapshot no longer carries ahead/behind — the dominant
1292 // per-worktree cost when computed eagerly every tick — so a client
1293 // (the extension on expand, `worktrees tree`) asks for it here only
1294 // for the worktrees it is about to show. Batched by path, one op per
1295 // repo expand; the git walks run on a blocking thread.
1296 let paths = payload
1297 .get("paths")
1298 .and_then(Value::as_array)
1299 .map(|arr| {
1300 arr.iter()
1301 .filter_map(Value::as_str)
1302 .map(PathBuf::from)
1303 .collect::<Vec<_>>()
1304 })
1305 .unwrap_or_default();
1306 Ok(json!({ "results": ahead_behind_results(paths).await }))
1307 }
1308 "set-show-closed" => {
1309 // The daemon-backed show/hide-closed toggle (#1301). Setting it
1310 // bumps the change-notify, so every subscribed window re-pushes a
1311 // snapshot carrying the new `show_closed` — reliable cross-window
1312 // sync `context.globalState` could not do.
1313 let show_closed = payload
1314 .get("show_closed")
1315 .and_then(Value::as_bool)
1316 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1317 self.registry.set_show_closed(show_closed);
1318 Ok(json!({ "ok": true }))
1319 }
1320 "set-polling" => {
1321 // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1322 // poller resolving its badges (default is off — zero `gh`);
1323 // disabling stops it and drops its badges. `set_polling` bumps the
1324 // change-notify on a real change, so every subscribed window
1325 // re-pushes a `tree` snapshot carrying the new per-repo
1326 // `polling_enabled` — the reliable cross-window sync the
1327 // `set-show-closed` precedent relies on. A changed value is
1328 // persisted so it survives a daemon restart.
1329 let owner = require_str(&payload, "owner", "set-polling")?;
1330 let name = require_str(&payload, "name", "set-polling")?;
1331 let enabled = payload
1332 .get("enabled")
1333 .and_then(Value::as_bool)
1334 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1335 if owner.trim().is_empty() || name.trim().is_empty() {
1336 bail!("`set-polling` requires a non-empty `owner` and `name`");
1337 }
1338 if self.registry.set_polling(owner, name, enabled) {
1339 self.persist_polling_prefs();
1340 }
1341 Ok(json!({ "ok": true }))
1342 }
1343 "open-prs" => {
1344 // Serve "Open Pull Request…" (and the extension's transient badge
1345 // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1346 // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1347 // call instead of each shelling its own (the per-window burn
1348 // #1370/#1389 target). Repo-wide; the client filters by branch for a
1349 // worktree-scoped lookup, and answers a badged branch straight from
1350 // the snapshot (zero `gh`) without ever reaching here.
1351 let owner = require_str(&payload, "owner", "open-prs")?;
1352 let name = require_str(&payload, "name", "open-prs")?;
1353 if owner.trim().is_empty() || name.trim().is_empty() {
1354 bail!("`open-prs` requires a non-empty `owner` and `name`");
1355 }
1356 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1357 }
1358 "open" => {
1359 // Focus (or open — VS Code reuses an already-open window) an
1360 // arbitrary worktree folder supplied by a socket client, reusing
1361 // the tray's launcher path: `focus_window` resolves the launcher
1362 // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1363 // the absolute-existing-directory guard (which also blocks a
1364 // `-`-leading path being parsed by `code` as a flag). This is the
1365 // one op a socket *writer* can use to spawn `code`; see the
1366 // ADR-0040 threat model (#1266).
1367 let path = require_str(&payload, "path", "open")?;
1368 focus_window(Path::new(path))?;
1369 Ok(json!({ "ok": true }))
1370 }
1371 "close" => {
1372 // Close a worktree's window and (for a linked worktree)
1373 // **delete** it. Destructive, so all git logic stays in the
1374 // daemon (git2, never a shell) and the main working tree is
1375 // refused defensively — the UI gating is not the only guard.
1376 // See ADR-0049 and docs/worktrees-service.md.
1377 let req: CloseRequest =
1378 serde_json::from_value(payload).context("invalid `close` payload")?;
1379 self.close(req).await
1380 }
1381 other => bail!("unknown worktrees op: {other}"),
1382 }
1383 }
1384
1385 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1386 // The single streaming op: a live push of the repo/worktree `tree`
1387 // snapshot. Every other op falls through to the request→reply `handle`.
1388 if op != "subscribe" {
1389 return None;
1390 }
1391 Some(Box::new(WorktreesStream {
1392 // Every stream reads through the one shared cache, so N windows
1393 // sampling the same tick build the tree once, not N times (#1303).
1394 cache: self.tree_cache.clone(),
1395 // Capture the change source *now* — before the server takes its
1396 // initial snapshot — so a change racing that snapshot still wakes us.
1397 changes: self.registry.subscribe_changes(),
1398 }))
1399 }
1400
1401 fn menu(&self) -> MenuSnapshot {
1402 // Serve the snapshot the background task maintains off the main thread;
1403 // fall back to a one-off inline compute only before the first refresh
1404 // lands (or with no runtime — the unit tests). Never blocks on git here
1405 // in the daemon, honouring the trait's "cheap, must not block" contract.
1406 let cached = self
1407 .menu_cache
1408 .lock()
1409 .unwrap_or_else(PoisonError::into_inner)
1410 .clone();
1411 let items = cached.unwrap_or_else(|| {
1412 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1413 });
1414 MenuSnapshot {
1415 title: SUBMENU_TITLE.to_string(),
1416 items,
1417 }
1418 }
1419
1420 async fn menu_action(&self, action_id: &str) -> Result<()> {
1421 if let Some(key) = action_id.strip_prefix("focus:") {
1422 // The registry resolves the folder under its own lock and clones it
1423 // out, so the mutex is never held across the process launch.
1424 let folder = self
1425 .registry
1426 .first_folder(key)
1427 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
1428 focus_window(&folder)?;
1429 return Ok(());
1430 }
1431 bail!("unknown worktrees menu action: {action_id}")
1432 }
1433
1434 async fn status(&self) -> ServiceStatus {
1435 let entries = self.registry.list();
1436 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
1437 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
1438 let windows = enriched_windows(entries).await;
1439 ServiceStatus {
1440 name: SERVICE_NAME.to_string(),
1441 healthy: true,
1442 summary,
1443 detail: json!({ "windows": windows }),
1444 }
1445 }
1446
1447 async fn shutdown(&self) {
1448 // Stop the background menu-refresh task; the registry itself is in-memory
1449 // with nothing to drain or persist. Take the task out from under the lock
1450 // first so the `std::Mutex` is never held across the `.await`.
1451 let task = self
1452 .refresh
1453 .lock()
1454 .unwrap_or_else(PoisonError::into_inner)
1455 .take();
1456 if let Some(task) = task {
1457 task.token.cancel();
1458 let _ = task.handle.await;
1459 }
1460 // Same discipline for the PR badge poller (#1337): take it out from under
1461 // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
1462 let poller = self
1463 .poller
1464 .lock()
1465 .unwrap_or_else(PoisonError::into_inner)
1466 .take();
1467 if let Some(poller) = poller {
1468 poller.token.cancel();
1469 let _ = poller.handle.await;
1470 }
1471 // And the GitHub rate-limit poller (#1375), same discipline.
1472 let rate_limit_poller = self
1473 .rate_limit_poller
1474 .lock()
1475 .unwrap_or_else(PoisonError::into_inner)
1476 .take();
1477 if let Some(poller) = rate_limit_poller {
1478 poller.token.cancel();
1479 let _ = poller.handle.await;
1480 }
1481 }
1482}
1483
1484/// Extracts a required string `field` from an op payload, erroring with the op
1485/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
1486/// (`key`) and `open` (`path`).
1487fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
1488 payload
1489 .get(field)
1490 .and_then(Value::as_str)
1491 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
1492}
1493
1494/// The live git state of a worktree folder: the checked-out branch and how far
1495/// it has diverged from its upstream. Computed on read from the on-disk repo
1496/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
1497/// snapshot taken at registration.
1498///
1499/// Every field is optional and degrades independently: a folder that is not a
1500/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
1501/// listed — just without the fields it cannot supply. The `skip_serializing_if`
1502/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
1503/// omitting each absent field on the wire.
1504#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
1505struct GitStatus {
1506 /// The checked-out branch, or `None` when detached or not in a repo.
1507 #[serde(skip_serializing_if = "Option::is_none")]
1508 branch: Option<String>,
1509 /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
1510 /// even on a detached HEAD, which has a commit but no branch. Rides the
1511 /// streamed snapshot so a new commit is a real delta the server's diff cannot
1512 /// drop — without it, a push serialises byte-identically and no client
1513 /// re-renders (#1337).
1514 #[serde(skip_serializing_if = "Option::is_none")]
1515 head_sha: Option<String>,
1516 /// The commit the branch's configured upstream ref points at, or `None`
1517 /// without an upstream (or when detached, unborn, or not in a repo). Rides
1518 /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
1519 /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
1520 /// field — `head_sha` included — byte-identical, so without this the frame
1521 /// serialised the same, the server's diff dropped it, and the lazily-fetched
1522 /// ahead/behind was never re-asked (#1344).
1523 #[serde(skip_serializing_if = "Option::is_none")]
1524 upstream_sha: Option<String>,
1525 /// Commits the branch is ahead of its upstream (`None` without an upstream).
1526 #[serde(skip_serializing_if = "Option::is_none")]
1527 ahead: Option<usize>,
1528 /// Commits the branch is behind its upstream (`None` without an upstream).
1529 #[serde(skip_serializing_if = "Option::is_none")]
1530 behind: Option<usize>,
1531 /// The main repository's directory name — the parent repo for a linked
1532 /// worktree, the checkout's own directory otherwise. Derived from git's
1533 /// common dir so a worktree names the repo it belongs to rather than its
1534 /// worktree-folder basename. `None` when not in a repo.
1535 #[serde(skip_serializing_if = "Option::is_none")]
1536 main_repo: Option<String>,
1537 /// Whether the enriched folder is a **linked** git worktree rather than the
1538 /// repository's main working tree. Omitted (false) for a normal checkout.
1539 #[serde(skip_serializing_if = "is_false")]
1540 is_worktree: bool,
1541}
1542
1543/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
1544/// field is dropped on the wire unless set — keeping older clients byte-identical
1545/// (the protocol's forward-compatibility convention).
1546#[allow(clippy::trivially_copy_pass_by_ref)]
1547fn is_false(b: &bool) -> bool {
1548 !*b
1549}
1550
1551/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
1552/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
1553/// daemon restart within the lease window keep the *remaining* time rather than
1554/// resetting the 15-minute clock; an already-expired entry is dropped on load.
1555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1556struct PollingLease {
1557 repo: String,
1558 expires_at: DateTime<Utc>,
1559}
1560
1561/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
1562/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
1563/// absence means not-polled (the default-off model) — so the file stays small (a
1564/// handful of active repos out of many open). `#[serde(default)]` so an
1565/// empty/older file decodes to "nothing enabled".
1566#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1567struct PollingPrefs {
1568 #[serde(default)]
1569 enabled: Vec<PollingLease>,
1570}
1571
1572/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
1573/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
1574/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
1575fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
1576 if let Some(parent) = path.parent() {
1577 crate::daemon::paths::ensure_dir_0700(parent)?;
1578 }
1579 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
1580 crate::daemon::paths::write_file_0600(path, &json)
1581}
1582
1583// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
1584
1585/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
1586///
1587/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
1588/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
1589/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
1590/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
1591/// compared against the worktree's current HEAD via
1592/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
1593/// `head_oid` would render every restored badge stale — so it carries the field
1594/// explicitly under a stable snake_case name.
1595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1596struct PersistedBadge {
1597 number: u64,
1598 is_draft: bool,
1599 checks: PrCheckState,
1600 url: String,
1601 head_oid: String,
1602}
1603
1604/// A persisted resolution — the disk twin of
1605/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
1606/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
1607/// `{ "Pr": { … } }`.
1608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1609enum PersistedResolution {
1610 Pr(PersistedBadge),
1611 NoPr,
1612}
1613
1614/// One persisted cache entry: which target, and the verdict last resolved for it.
1615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1616struct PersistedEntry {
1617 target: PrTarget,
1618 resolution: PersistedResolution,
1619}
1620
1621/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
1622/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
1623/// immediate re-poll" from "a target was added or a branch pushed while we were
1624/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
1625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1626struct PersistedWatch {
1627 target: PrTarget,
1628 #[serde(default, skip_serializing_if = "Option::is_none")]
1629 upstream_sha: Option<String>,
1630}
1631
1632/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
1633/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
1634/// so an empty/older/partial file decodes to "nothing restored" rather than
1635/// failing the load — best-effort, exactly like [`PollingPrefs`].
1636#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1637struct PrCachePrefs {
1638 #[serde(default)]
1639 entries: Vec<PersistedEntry>,
1640 #[serde(default)]
1641 watched: Vec<PersistedWatch>,
1642 #[serde(default, skip_serializing_if = "Option::is_none")]
1643 polled_at: Option<DateTime<Utc>>,
1644}
1645
1646impl PersistedResolution {
1647 /// The disk form of a live resolution.
1648 fn from_resolution(r: &PrResolution) -> Self {
1649 match r {
1650 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
1651 number: b.number,
1652 is_draft: b.is_draft,
1653 checks: b.checks,
1654 url: b.url.clone(),
1655 head_oid: b.head_oid.clone(),
1656 }),
1657 PrResolution::NoPr => Self::NoPr,
1658 }
1659 }
1660
1661 /// The live form of a restored resolution.
1662 fn into_resolution(self) -> PrResolution {
1663 match self {
1664 Self::Pr(b) => PrResolution::Pr(PrBadge {
1665 number: b.number,
1666 is_draft: b.is_draft,
1667 checks: b.checks,
1668 url: b.url,
1669 head_oid: b.head_oid,
1670 }),
1671 Self::NoPr => PrResolution::NoPr,
1672 }
1673 }
1674}
1675
1676/// Assembles the on-disk cache from the live cache entries, the watch they were
1677/// resolved for, and the poll time.
1678fn pr_cache_prefs_from(
1679 entries: Vec<(PrTarget, PrResolution)>,
1680 watched: &[PrWatch],
1681 polled_at: DateTime<Utc>,
1682) -> PrCachePrefs {
1683 let mut entries: Vec<PersistedEntry> = entries
1684 .into_iter()
1685 .map(|(target, resolution)| PersistedEntry {
1686 target,
1687 resolution: PersistedResolution::from_resolution(&resolution),
1688 })
1689 .collect();
1690 // Stable on-disk order so the file does not churn on rewrite from `HashMap`
1691 // iteration order alone (the same reason `PollingPrefs` sorts).
1692 entries.sort_by(|a, b| a.target.cmp(&b.target));
1693 let mut watched: Vec<PersistedWatch> = watched
1694 .iter()
1695 .map(|w| PersistedWatch {
1696 target: w.target.clone(),
1697 upstream_sha: w.upstream_sha.clone(),
1698 })
1699 .collect();
1700 watched.sort_by(|a, b| a.target.cmp(&b.target));
1701 PrCachePrefs {
1702 entries,
1703 watched,
1704 polled_at: Some(polled_at),
1705 }
1706}
1707
1708/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
1709/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
1710fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
1711 if let Some(parent) = path.parent() {
1712 crate::daemon::paths::ensure_dir_0700(parent)?;
1713 }
1714 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
1715 crate::daemon::paths::write_file_0600(path, &json)
1716}
1717
1718/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
1719/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
1720/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
1721/// in-memory cache is authoritative for the running daemon and losing the warm
1722/// start only costs one extra poll after the next restart.
1723fn persist_pr_cache(
1724 path: &Path,
1725 pr_cache: &PrStatusCache,
1726 watched: &[PrWatch],
1727 polled_at: DateTime<Utc>,
1728) {
1729 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
1730 if let Err(err) = write_pr_cache(path, &prefs) {
1731 let at = path.display();
1732 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
1733 }
1734}
1735
1736/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
1737/// the watch set the previous daemon last resolved and when it last polled. The
1738/// poller seeds its loop from this so a restart within the backoff window skips
1739/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
1740/// call to re-derive what the `0600` file already carries.
1741#[derive(Debug, Clone)]
1742struct PrWarmStart {
1743 /// The `(target, upstream_sha)` set the persisted verdicts describe.
1744 watched: Vec<PrWatch>,
1745 /// When those verdicts were resolved, used to age the warm start against the
1746 /// backoff (a stale-enough file just re-polls).
1747 polled_at: DateTime<Utc>,
1748}
1749
1750// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
1751
1752/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
1753/// fetched, for TTL expiry.
1754#[derive(Debug, Clone)]
1755struct OpenPrEntry {
1756 at: Instant,
1757 prs: Vec<Value>,
1758}
1759
1760/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
1761///
1762/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
1763/// from the daemon means N windows asking about one repo dedupe to a single counted
1764/// `gh pr list` within the TTL, instead of each window shelling its own (the
1765/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
1766/// the access pattern is a manual action (or a brief post-enable transient), so two
1767/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
1768/// and harmless, while the common repeat-within-TTL is served for free. The lock is
1769/// never held across an `.await`.
1770#[derive(Debug)]
1771struct OpenPrCache {
1772 entries: Mutex<HashMap<String, OpenPrEntry>>,
1773 ttl: Duration,
1774}
1775
1776impl OpenPrCache {
1777 fn new(ttl: Duration) -> Self {
1778 Self {
1779 entries: Mutex::new(HashMap::new()),
1780 ttl,
1781 }
1782 }
1783
1784 /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
1785 /// `None` (a miss that the caller resolves with a fresh `gh`).
1786 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
1787 self.entries
1788 .lock()
1789 .unwrap_or_else(PoisonError::into_inner)
1790 .get(key)
1791 .filter(|e| e.at.elapsed() < self.ttl)
1792 .map(|e| e.prs.clone())
1793 }
1794
1795 /// Records a freshly-fetched PR list for `key`.
1796 fn store(&self, key: String, prs: Vec<Value>) {
1797 self.entries
1798 .lock()
1799 .unwrap_or_else(PoisonError::into_inner)
1800 .insert(
1801 key,
1802 OpenPrEntry {
1803 at: Instant::now(),
1804 prs,
1805 },
1806 );
1807 }
1808}
1809
1810/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
1811/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
1812/// call on a blocking thread, never an async worker. The array is forwarded to the
1813/// extension verbatim, which parses it into its `PullRequest` shape.
1814fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
1815 let output = crate::github_metrics::run_gh(
1816 bin,
1817 [
1818 "pr",
1819 "list",
1820 "--repo",
1821 slug,
1822 "--state",
1823 "open",
1824 "--json",
1825 OPEN_PR_JSON_FIELDS,
1826 "--limit",
1827 OPEN_PR_LIST_LIMIT,
1828 ],
1829 "pr list",
1830 None,
1831 )
1832 .with_context(|| {
1833 format!(
1834 "failed to run {} (is the GitHub CLI installed?)",
1835 bin.display()
1836 )
1837 })?;
1838 if !output.status.success() {
1839 let stderr = String::from_utf8_lossy(&output.stderr);
1840 bail!("gh pr list failed: {}", stderr.trim());
1841 }
1842 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
1843 Value::Array(arr) => Ok(arr),
1844 _ => bail!("gh pr list did not return a JSON array"),
1845 }
1846}
1847
1848/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
1849/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
1850/// op and the tray menu, both bounded to the (few) open windows, where the extra
1851/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
1852/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
1853fn git_status(folder: &Path) -> GitStatus {
1854 git_status_impl(folder, true)
1855}
1856
1857/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
1858/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
1859/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
1860/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
1861/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
1862/// fields stay `None`, so they are omitted on the wire exactly as for a branch
1863/// with no upstream.
1864fn git_status_cheap(folder: &Path) -> GitStatus {
1865 git_status_impl(folder, false)
1866}
1867
1868/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
1869/// repository that contains `folder` — so a subdirectory or a linked worktree both
1870/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
1871/// divergence. Every failure mode degrades to an empty status rather than
1872/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
1873fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
1874 let Ok(repo) = Repository::discover(folder) else {
1875 return GitStatus::default();
1876 };
1877 // Repo identity applies even when HEAD is unborn or detached, so a worktree
1878 // still names its parent repo (and is flagged as a worktree) in those states.
1879 let base = GitStatus {
1880 main_repo: main_repo_name(repo.commondir()),
1881 is_worktree: repo.is_worktree(),
1882 ..GitStatus::default()
1883 };
1884 let Ok(head) = repo.head() else {
1885 // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
1886 return base;
1887 };
1888 // Resolved here — before the branch filter below, so a detached HEAD still
1889 // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
1890 // a refs read: no revwalk and no object lookup, so unlike the divergence walk
1891 // it is cheap enough for the streamed snapshot's every-worktree-every-tick
1892 // rebuild (#1306's bar).
1893 let base = GitStatus {
1894 head_sha: head.target().map(|oid| oid.to_string()),
1895 ..base
1896 };
1897 // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
1898 // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
1899 // name — degrades to no branch through this one path.
1900 let Some(name) = head
1901 .shorthand()
1902 .ok()
1903 .filter(|_| head.is_branch())
1904 .map(str::to_string)
1905 else {
1906 return base;
1907 };
1908 // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
1909 // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
1910 // below costs the cheap path nothing, and is what gives it a handle to
1911 // resolve the upstream from.
1912 let branch = git2::Branch::wrap(head);
1913 // Unlike the divergence walk, this rides both paths: it is what makes a push
1914 // a visible delta (#1344).
1915 let upstream_sha = upstream_target(&branch);
1916 // The divergence walk is the dominant per-worktree cost, so the cheap path
1917 // skips it and leaves ahead/behind absent.
1918 let (ahead, behind) = if with_ahead_behind {
1919 match upstream_ahead_behind(&repo, &branch) {
1920 Some((ahead, behind)) => (Some(ahead), Some(behind)),
1921 None => (None, None),
1922 }
1923 } else {
1924 (None, None)
1925 };
1926 GitStatus {
1927 branch: Some(name),
1928 upstream_sha,
1929 ahead,
1930 behind,
1931 ..base
1932 }
1933}
1934
1935/// The commit `branch`'s configured upstream ref points at, or `None` when it
1936/// tracks no upstream (or the ref is unresolvable).
1937///
1938/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
1939/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
1940/// refs read, but still **no revwalk and no object lookup**, which is the bar
1941/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
1942/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
1943/// same OID, so it is proven reachable.
1944fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
1945 Some(branch.upstream().ok()?.get().target()?.to_string())
1946}
1947
1948/// The ahead/behind divergence of `folder`'s checked-out branch versus its
1949/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
1950/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
1951/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
1952/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
1953/// sync indicator.
1954fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
1955 let repo = Repository::discover(folder).ok()?;
1956 let head = repo.head().ok()?;
1957 if !head.is_branch() {
1958 return None;
1959 }
1960 let branch = git2::Branch::wrap(head);
1961 upstream_ahead_behind(&repo, &branch)
1962}
1963
1964/// The main repository's directory name from git's common dir. For the usual
1965/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
1966/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
1967/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
1968/// no name can be derived.
1969fn main_repo_name(commondir: &Path) -> Option<String> {
1970 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
1971 if file_name == ".git" {
1972 // Normal layout: the repo is the directory that contains `.git`.
1973 commondir
1974 .parent()
1975 .and_then(Path::file_name)
1976 .map(|n| n.to_string_lossy().into_owned())
1977 } else {
1978 // A bare repo: use its own directory name, without any `.git` suffix.
1979 Some(
1980 file_name
1981 .strip_suffix(".git")
1982 .unwrap_or(&file_name)
1983 .to_string(),
1984 )
1985 }
1986}
1987
1988/// Ahead/behind commit counts of `branch` versus its configured upstream, or
1989/// `None` when the branch tracks no upstream (or either tip is unresolvable).
1990fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
1991 let upstream = branch.upstream().ok()?;
1992 let local_oid = branch.get().target()?;
1993 let upstream_oid = upstream.get().target()?;
1994 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
1995}
1996
1997/// The wire shape of an enriched window: the stored entry fields plus the
1998/// daemon-computed git state, flattened into one JSON object. Serializing
1999/// through a single struct (rather than mutating a `Value`) keeps every present
2000/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2001/// the absent git fields — no manual per-field insertion.
2002#[derive(Serialize)]
2003struct EnrichedEntry<'a> {
2004 #[serde(flatten)]
2005 entry: &'a WindowEntry,
2006 #[serde(flatten)]
2007 git: GitStatus,
2008}
2009
2010/// Serializes a registry entry and folds in the live [`git_status`] of its
2011/// primary (first) folder, producing the JSON object served on the wire
2012/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2013/// enriched — it is the one the table shows and the "focus" action opens.
2014fn enriched_entry(entry: &WindowEntry) -> Value {
2015 let git = entry
2016 .folders
2017 .first()
2018 .map(|folder| git_status(folder))
2019 .unwrap_or_default();
2020 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2021}
2022
2023/// Enriches a batch of entries with their git state on a blocking thread, since
2024/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2025/// handler. A join failure degrades to an empty list rather than erroring.
2026async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2027 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2028 .await
2029 .unwrap_or_default()
2030}
2031
2032// --- Repo/worktree tree (#1265) ----------------------------------------------
2033
2034/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2035/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2036/// non-GitHub (or remote-less) repo omits it.
2037#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2038struct GithubIdentity {
2039 /// The repository owner (user or org) — the first path segment.
2040 owner: String,
2041 /// The repository name, with any `.git` suffix stripped.
2042 name: String,
2043}
2044
2045/// One worktree of a repository in the `tree` payload: its path, live git state,
2046/// whether it is the main working tree, and whether a VS Code window currently
2047/// has it open (with that window's key, for the focus action). Optional git
2048/// fields degrade independently, exactly like [`GitStatus`].
2049///
2050/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2051/// the dominant per-worktree cost when computed eagerly for every worktree on
2052/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2053/// worktrees a client actually shows (#1306).
2054///
2055/// The two **OIDs** the divergence is computed from — `head_sha` and
2056/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2057/// refs read rather than a commit-graph walk, and between them they are what makes
2058/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2059/// re-ask for the counts it left behind.
2060#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2061struct TreeWorktree {
2062 /// Absolute path to the worktree's working directory.
2063 path: String,
2064 /// The checked-out branch, or `None` when detached or unborn.
2065 #[serde(skip_serializing_if = "Option::is_none")]
2066 branch: Option<String>,
2067 /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2068 /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2069 /// commit a visible delta, so a push re-renders instead of being dropped by the
2070 /// server's snapshot diff (#1337).
2071 #[serde(skip_serializing_if = "Option::is_none")]
2072 head_sha: Option<String>,
2073 /// The commit the branch's upstream ref points at, or `None` without an
2074 /// upstream. The push counterpart of `head_sha`: a push moves only
2075 /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2076 /// making the snapshot a real delta the server's diff cannot drop, which is
2077 /// what re-fetches the lazy ahead/behind (#1344).
2078 #[serde(skip_serializing_if = "Option::is_none")]
2079 upstream_sha: Option<String>,
2080 /// Whether this is the repository's main working tree (vs a linked worktree).
2081 is_main: bool,
2082 /// Whether a live VS Code window currently has this worktree open.
2083 open: bool,
2084 /// The open window's registry key, when `open` — the handle a focus action
2085 /// resolves. Absent for a worktree with no open window.
2086 #[serde(skip_serializing_if = "Option::is_none")]
2087 window_key: Option<String>,
2088 /// The open PR whose head is this worktree's branch, with its CI verdict
2089 /// (#1337). Resolved by the daemon's background poller and folded on as the
2090 /// snapshot is built, so every open window sees the same live state without
2091 /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2092 /// with no open PR (see `pr_none`), or until the first poll lands.
2093 #[serde(skip_serializing_if = "Option::is_none")]
2094 pr: Option<PrBadge>,
2095 /// Set when the daemon **checked GitHub and found no open PR** for this
2096 /// worktree's branch — the explicit negative (#1370), mutually exclusive
2097 /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2098 /// non-GitHub worktree, and — crucially — while the branch is simply **not
2099 /// yet resolved** (before the first poll lands, or ever, on a failed one):
2100 /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2101 /// older client stays byte-identical (ADR-0053). Clients use it to keep
2102 /// their degraded per-window `gh pr list` fallback quiet for branches the
2103 /// daemon has already answered for.
2104 #[serde(skip_serializing_if = "is_false")]
2105 pr_none: bool,
2106}
2107
2108/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2109/// derived from the distinct open windows; a repo leaves the tree when its last
2110/// window closes (the open-window-derived model, ADR-0040 / #1264).
2111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2112struct TreeRepo {
2113 /// The main repository's directory name (see [`main_repo_name`]).
2114 main_repo: String,
2115 /// The GitHub identity of `origin`, when it is a `github.com` remote.
2116 #[serde(skip_serializing_if = "Option::is_none")]
2117 github: Option<GithubIdentity>,
2118 /// Absolute path to the main working tree — the repo's root.
2119 root: String,
2120 /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2121 /// registry's per-repo enable set, which defaults **off**, so it is omitted
2122 /// (false) for the common not-polled repo — keeping older clients
2123 /// byte-identical — and present (`true`) only for a repo the user has
2124 /// explicitly enabled. The extension colours the repo icon green when set and
2125 /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2126 /// on it so a not-polled repo issues zero `gh`.
2127 #[serde(skip_serializing_if = "is_false")]
2128 polling_enabled: bool,
2129 /// Every worktree of the repo: the main working tree first, then linked
2130 /// worktrees sorted by path.
2131 worktrees: Vec<TreeWorktree>,
2132}
2133
2134/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2135/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2136/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2137/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2138/// slashes are stripped; anything with an empty or extra path segment is
2139/// rejected (best-effort, never panics).
2140fn github_identity(url: &str) -> Option<GithubIdentity> {
2141 let url = url.trim();
2142 // Reduce every supported form to the `owner/name…` tail after the host.
2143 let rest = [
2144 "https://github.com/",
2145 "http://github.com/",
2146 "ssh://git@github.com/",
2147 "git://github.com/",
2148 "git@github.com:",
2149 ]
2150 .iter()
2151 .find_map(|prefix| url.strip_prefix(prefix))?;
2152 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2153 let rest = rest.trim_end_matches('/');
2154 let mut parts = rest.splitn(2, '/');
2155 let owner = parts.next()?.trim();
2156 let name = parts.next()?.trim();
2157 // A well-formed identity has exactly two non-empty segments.
2158 if owner.is_empty() || name.is_empty() || name.contains('/') {
2159 return None;
2160 }
2161 Some(GithubIdentity {
2162 owner: owner.to_string(),
2163 name: name.to_string(),
2164 })
2165}
2166
2167/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2168/// `github.com` remote found. `None` when no remote is a GitHub remote.
2169fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2170 if let Ok(origin) = repo.find_remote("origin") {
2171 if let Some(id) = origin.url().ok().and_then(github_identity) {
2172 return Some(id);
2173 }
2174 }
2175 // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2176 // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2177 // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2178 let names = repo.remotes().ok();
2179 names
2180 .iter()
2181 .flat_map(|arr| arr.iter())
2182 .flatten()
2183 .flatten()
2184 .filter_map(|name| repo.find_remote(name).ok())
2185 .find_map(|remote| remote.url().ok().and_then(github_identity))
2186}
2187
2188/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2189/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2190/// no longer exists) so the join still degrades gracefully.
2191fn canonical(path: &Path) -> PathBuf {
2192 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2193}
2194
2195/// Indexes the open windows by canonicalized workspace-folder path → window key,
2196/// so a worktree path can be joined back to the window (if any) that has it open.
2197/// The first window wins a shared folder; `entries` arrive in a deterministic
2198/// (repo, key) order, so the choice is stable.
2199fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2200 let mut index = HashMap::new();
2201 for entry in entries {
2202 for folder in &entry.folders {
2203 index
2204 .entry(canonical(folder))
2205 .or_insert_with(|| entry.key.clone());
2206 }
2207 }
2208 index
2209}
2210
2211/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2212/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2213/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2214/// by the caller from the enumeration (main working tree vs linked).
2215fn worktree_entry(
2216 path: &Path,
2217 is_main: bool,
2218 open_index: &HashMap<PathBuf, String>,
2219) -> TreeWorktree {
2220 let status = git_status_cheap(path);
2221 let window_key = open_index.get(&canonical(path)).cloned();
2222 TreeWorktree {
2223 path: path.display().to_string(),
2224 branch: status.branch,
2225 head_sha: status.head_sha,
2226 upstream_sha: status.upstream_sha,
2227 is_main,
2228 open: window_key.is_some(),
2229 window_key,
2230 // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2231 // identity — known one level up, in `repo_tree`.
2232 pr: None,
2233 pr_none: false,
2234 }
2235}
2236
2237/// Folds the poller's cached PR resolutions onto each worktree of each repo
2238/// (#1337).
2239///
2240/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2241/// identity, branch) and the identity is only known once the repo is assembled.
2242/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2243/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
2244/// keeps `pr: None`/`pr_none: false` and renders nothing.
2245///
2246/// A verdict computed for a **different commit** than the worktree has checked out
2247/// is downgraded to pending here rather than shown as-is. That is what makes a push
2248/// invalidate the badge the moment it happens: the cache still holds the previous
2249/// commit's verdict, and this fold — which runs on every snapshot — notices without
2250/// waiting for a poll. Without it the previous head's `✓` stands until the poller
2251/// next runs, which is up to the full backoff.
2252///
2253/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
2254/// when `head_sha` moves: it has no commit to be stale against, and dropping it
2255/// would re-arm every client's `gh` fallback on every local commit. The poller's
2256/// `moved` trigger re-checks the branch within one fast poll anyway.
2257/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
2258/// enable set (#1376).
2259///
2260/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
2261/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
2262/// enabled carries neither the flag nor any badge. Purely a set membership check —
2263/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
2264/// and stays `false`; it never polls anyway.
2265fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2266 for repo in repos {
2267 if let Some(github) = &repo.github {
2268 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
2269 }
2270 }
2271}
2272
2273fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
2274 for repo in repos {
2275 // A not-polled repo (#1376) never carries a badge: skip it so a repo the
2276 // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
2277 // the flag, and so the icon greys cross-window on the next pushed snapshot.
2278 if !repo.polling_enabled {
2279 continue;
2280 }
2281 let Some(github) = repo.github.clone() else {
2282 continue;
2283 };
2284 for worktree in &mut repo.worktrees {
2285 let Some(branch) = &worktree.branch else {
2286 continue;
2287 };
2288 match pr_cache.get(&github.owner, &github.name, branch) {
2289 Some(PrResolution::Pr(mut badge)) => {
2290 if badge.is_stale_for(worktree.head_sha.as_deref()) {
2291 badge.checks = PrCheckState::Pending;
2292 }
2293 worktree.pr = Some(badge);
2294 }
2295 Some(PrResolution::NoPr) => worktree.pr_none = true,
2296 None => {}
2297 }
2298 }
2299 }
2300}
2301
2302/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
2303/// handle discovered from one of its folders. Opens the **main** repo from the
2304/// shared common dir's parent so the main working tree and every linked worktree
2305/// are enumerated regardless of which one seeded the discovery. `None` for a
2306/// bare or otherwise root-less repo (no working tree to show).
2307fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
2308 // The common dir (`…/<root>/.git`) is shared by the main checkout and all
2309 // linked worktrees; its parent is the main working tree.
2310 let commondir = canonical(discovered.commondir());
2311 let main_root = commondir.parent()?.to_path_buf();
2312 let main_repo = Repository::open(&main_root).ok()?;
2313
2314 // Main working tree first.
2315 let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
2316 // Then every linked worktree, sorted by path for deterministic output. The
2317 // `StringArray` of names is bound so `iter()` can borrow it (only
2318 // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
2319 // worktree is skipped.
2320 let names = main_repo.worktrees().ok();
2321 let mut linked: Vec<PathBuf> = names
2322 .iter()
2323 .flat_map(|arr| arr.iter())
2324 .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
2325 .flatten() // Option<&str> → &str (drop non-UTF-8 names)
2326 .filter_map(|name| main_repo.find_worktree(name).ok())
2327 .map(|wt| wt.path().to_path_buf())
2328 .collect();
2329 linked.sort();
2330 worktrees.extend(
2331 linked
2332 .iter()
2333 .map(|path| worktree_entry(path, false, open_index)),
2334 );
2335
2336 Some(TreeRepo {
2337 main_repo: main_repo_name(&commondir)?,
2338 github: remote_github_identity(&main_repo),
2339 root: main_root.display().to_string(),
2340 // Defaults off; `stamp_polling` sets it from the registry's enable set
2341 // once the repo (and thus its GitHub identity) is assembled.
2342 polling_enabled: false,
2343 worktrees,
2344 })
2345}
2346
2347/// Resolves the seed `folders` to their distinct repositories and enumerates
2348/// each repo's worktrees. Dedupes repos by their common dir (shared across a
2349/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
2350/// is not in a git repo is skipped. Pure blocking git I/O — call it via
2351/// [`tree_repos`], never under the registry lock.
2352fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
2353 let open_index = open_window_index(&windows);
2354 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
2355 for folder in &folders {
2356 let Ok(repo) = Repository::discover(folder) else {
2357 continue;
2358 };
2359 let key = canonical(repo.commondir());
2360 if repos.contains_key(&key) {
2361 continue;
2362 }
2363 if let Some(tree) = repo_tree(&repo, &open_index) {
2364 repos.insert(key, tree);
2365 }
2366 }
2367 repos.into_values().collect()
2368}
2369
2370/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
2371/// does synchronous disk I/O and this runs inside the async control-socket
2372/// handler), returning the serialized `repos` array. A join failure degrades to
2373/// an empty list rather than erroring, matching [`enriched_windows`].
2374async fn tree_repos(
2375 folders: Vec<PathBuf>,
2376 windows: Vec<WindowEntry>,
2377 pr_cache: Arc<PrStatusCache>,
2378 enabled_polling: HashSet<String>,
2379) -> Vec<Value> {
2380 tokio::task::spawn_blocking(move || {
2381 let mut repos = build_tree(folders, windows);
2382 // Stamp per-repo poll state first so `fold_pr_badges` can skip a
2383 // not-polled repo — a disabled repo carries neither the flag nor a badge.
2384 stamp_polling(&mut repos, &enabled_polling);
2385 fold_pr_badges(&mut repos, &pr_cache);
2386 repos
2387 .iter()
2388 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
2389 .collect()
2390 })
2391 .await
2392 .unwrap_or_default()
2393}
2394
2395// --- Lazy ahead/behind (#1306) -----------------------------------------------
2396
2397/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
2398/// returning a JSON object keyed by the **requested** path string:
2399/// `{ "<path>": { "ahead": n, "behind": m }, … }`. A path with no upstream (or that
2400/// is not a repo / is detached) is **omitted** — the client renders it without a
2401/// sync indicator, exactly as the tree does for an absent `ahead`/`behind`.
2402///
2403/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
2404/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
2405/// shows (the extension on expand), not for every worktree on every tick. The git
2406/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
2407/// degrades to an empty object rather than erroring.
2408async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
2409 tokio::task::spawn_blocking(move || {
2410 let mut results = serde_json::Map::new();
2411 for path in paths {
2412 if let Some((ahead, behind)) = folder_ahead_behind(&path) {
2413 results.insert(
2414 path.display().to_string(),
2415 json!({ "ahead": ahead, "behind": behind }),
2416 );
2417 }
2418 }
2419 Value::Object(results)
2420 })
2421 .await
2422 .unwrap_or_else(|_| json!({}))
2423}
2424
2425// --- Push subscription (#1267) -----------------------------------------------
2426
2427/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
2428/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
2429/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
2430/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
2431/// type only has to (a) relay the registry's change-notify and (b) read the
2432/// tree snapshot on demand.
2433///
2434/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
2435/// is built at most once per tick and fanned out, rather than each stream
2436/// rebuilding the identical tree. This type holds only cheap handles — a clone
2437/// of the shared cache and its own change-notify receiver.
2438struct WorktreesStream {
2439 /// The shared coalescing cache the snapshot is read through, so every
2440 /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
2441 cache: Arc<TreeSnapshotCache>,
2442 /// Wakes on each visible-set change (a `register`, a removing `unregister`,
2443 /// or a mutation-driven reap). A burst coalesces into one wakeup; the
2444 /// server's diff drops any snapshot that ends up identical.
2445 changes: watch::Receiver<u64>,
2446}
2447
2448#[async_trait]
2449impl ServiceStream for WorktreesStream {
2450 async fn changed(&mut self) {
2451 // `watch::Receiver::changed` marks the newest version seen, so a burst of
2452 // bumps collapses into a single wakeup. If every sender is gone (the
2453 // registry — and thus the daemon — is tearing down) it returns `Err`;
2454 // park instead of returning, so this arm can never spin the server's
2455 // `select!` (the tick and shutdown arms still drive teardown).
2456 if self.changes.changed().await.is_err() {
2457 std::future::pending::<()>().await;
2458 }
2459 }
2460
2461 async fn snapshot(&self) -> Value {
2462 // Read through the shared coalescing cache. The value is built by the
2463 // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
2464 // live push agree byte-for-byte — but here it is built once per tick and
2465 // shared across every subscriber rather than rebuilt per stream (#1303).
2466 self.cache.snapshot().await
2467 }
2468}
2469
2470/// A coalescing cache for the global tree snapshot (#1303).
2471///
2472/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
2473/// server re-samples each on its own `STREAM_TICK` and on every registry change
2474/// — so with N windows the *identical* global tree was being built N times per
2475/// tick. This cache collapses that to **one** build: all streams share it, and
2476/// it rebuilds at most once per `ttl` (the stream tick) per registry
2477/// change-generation.
2478///
2479/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
2480/// exactly as before:
2481/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
2482/// still matches — a `register`/`unregister`/toggle bumps it and forces a
2483/// fresh build, so subscribers never see a stale visible set; and
2484/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
2485/// branch switch, new commits), which fires no registry event, still surfaces
2486/// within one tick.
2487///
2488/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
2489/// callers, so a burst of N streams waking on the same tick/change performs one
2490/// build while the rest wait and read the shared result. The one-shot `tree` op
2491/// bypasses this and computes fresh — it is a rare manual refresh, not part of
2492/// the per-tick fan-out.
2493struct TreeSnapshotCache {
2494 /// The registry every snapshot is built from, and whose change-generation
2495 /// gates cache reuse.
2496 registry: Arc<WorktreesRegistry>,
2497 /// PR badges folded onto each worktree as the snapshot is built (#1337).
2498 /// Written by the background poller; read here. A miss simply omits `pr`.
2499 pr_cache: Arc<PrStatusCache>,
2500 /// How long a built snapshot stays fresh before a tick-driven read rebuilds
2501 /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
2502 /// coalesced build runs at most once per tick; tests inject a shorter value.
2503 ttl: Duration,
2504 /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
2505 /// because it is deliberately held across the `.await` of the git
2506 /// enumeration, so concurrent callers serialize onto one build rather than
2507 /// each computing their own.
2508 state: AsyncMutex<Option<CachedTree>>,
2509 /// How many times the tree was actually (re)built — so tests can assert the
2510 /// coalescing collapses an N-stream burst into one build. Cheap and always
2511 /// maintained; only read under `#[cfg(test)]`.
2512 computes: AtomicU64,
2513}
2514
2515/// One cached tree snapshot: the shared value plus the two freshness stamps
2516/// [`TreeSnapshotCache`] checks before reusing it.
2517struct CachedTree {
2518 /// The registry change-generation captured *before* the build, so a change
2519 /// racing the build advances the generation and the next read rebuilds
2520 /// (conservative: it may rebuild once needlessly, but never serves stale).
2521 generation: u64,
2522 /// When the value was built, for the `ttl` staleness check.
2523 computed_at: Instant,
2524 /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
2525 /// subscriber by cloning the `Arc`'s inner value.
2526 value: Arc<Value>,
2527}
2528
2529impl TreeSnapshotCache {
2530 /// Creates a cache over `registry` with the default TTL — the server's
2531 /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
2532 /// build runs at most once per tick.
2533 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
2534 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
2535 }
2536
2537 /// Creates a cache with an explicit `ttl`, for tests that need a short (or
2538 /// long) freshness window without waiting a real tick.
2539 fn with_ttl(
2540 registry: Arc<WorktreesRegistry>,
2541 pr_cache: Arc<PrStatusCache>,
2542 ttl: Duration,
2543 ) -> Self {
2544 Self {
2545 registry,
2546 pr_cache,
2547 ttl,
2548 state: AsyncMutex::new(None),
2549 computes: AtomicU64::new(0),
2550 }
2551 }
2552
2553 /// The current tree snapshot, built at most once per `ttl` per registry
2554 /// change-generation and shared across all callers. See the type docs for
2555 /// the freshness and single-flight semantics.
2556 async fn snapshot(&self) -> Value {
2557 // Hold the lock across the whole check-and-build so concurrent callers
2558 // serialize onto one build (single-flight); reading the generation here
2559 // (before the build) means a change racing the build forces the *next*
2560 // read to rebuild rather than serving this now-stale value.
2561 let mut state = self.state.lock().await;
2562 let generation = self.registry.change_generation();
2563 // Reuse the cached value only while it matches the current generation
2564 // *and* is within the TTL; either failing forces a rebuild.
2565 let fresh = state.as_ref().and_then(|cached| {
2566 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
2567 .then(|| Arc::clone(&cached.value))
2568 });
2569 let value = if let Some(value) = fresh {
2570 value
2571 } else {
2572 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
2573 self.computes.fetch_add(1, Ordering::Relaxed);
2574 *state = Some(CachedTree {
2575 generation,
2576 computed_at: Instant::now(),
2577 value: Arc::clone(&value),
2578 });
2579 value
2580 };
2581 // Release the lock before the (deeper) clone of the shared value out.
2582 drop(state);
2583 (*value).clone()
2584 }
2585
2586 /// How many times the tree was actually built — the coalescing assertion in
2587 /// tests (N reads within one tick/generation should build once).
2588 #[cfg(test)]
2589 fn compute_count(&self) -> u64 {
2590 self.computes.load(Ordering::Relaxed)
2591 }
2592}
2593
2594/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
2595/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
2596/// (the seed folders to derive repos from, and the live windows to join on) and
2597/// a lock-free read of the toggle, then the git enumeration/enrichment off the
2598/// lock on a blocking thread inside [`tree_repos`].
2599async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
2600 let folders = registry.open_folders();
2601 let windows = registry.list();
2602 let show_closed = registry.show_closed();
2603 let enabled_polling = registry.enabled_polling_repos();
2604 json!({
2605 "repos": tree_repos(folders, windows, pr_cache, enabled_polling).await,
2606 "show_closed": show_closed,
2607 })
2608}
2609
2610/// A short human name for a window: its repo, else its first folder's basename,
2611/// else a placeholder.
2612fn display_name(entry: &WindowEntry) -> String {
2613 if let Some(repo) = &entry.repo {
2614 return repo.clone();
2615 }
2616 if let Some(folder) = entry.folders.first() {
2617 return folder.file_name().map_or_else(
2618 || folder.display().to_string(),
2619 |n| n.to_string_lossy().into_owned(),
2620 );
2621 }
2622 "(no folder)".to_string()
2623}
2624
2625/// Separator between the repo name and branch for a normal working tree.
2626const REPO_SEP: char = '·';
2627/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
2628/// line is distinguishable at a glance from its parent repo's main checkout.
2629const WORKTREE_SEP: char = '⑂';
2630
2631/// The full tray item list for a window set: the "No open windows" placeholder
2632/// when empty, else one line per window via [`window_menu_items`]. Does the git
2633/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
2634/// background refresh task — and inline only as a cold-start fallback in `menu`.
2635fn menu_items_for(
2636 entries: &[WindowEntry],
2637 rate_limit: Option<&RateLimitSnapshot>,
2638) -> Vec<MenuItem> {
2639 let mut items = Vec::new();
2640 // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
2641 // above the windows, so an approaching exhaustion is visible in the tray before
2642 // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
2643 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
2644 if !label.is_empty() {
2645 items.push(MenuItem::Label(label));
2646 items.push(MenuItem::Separator);
2647 }
2648 }
2649 if entries.is_empty() {
2650 items.push(MenuItem::Label("No open windows".to_string()));
2651 } else {
2652 items.extend(window_menu_items(entries));
2653 }
2654 items
2655}
2656
2657/// Builds the tray items for a non-empty window list: **one clickable line per
2658/// window** whose label carries the live git state and whose click focuses that
2659/// window. A window with no workspace folder has nothing for `code` to open, so
2660/// it stays a non-clickable status line. The labels read each worktree from disk
2661/// (via [`window_label`]) — cheap for a realistic window count and consistent
2662/// with reap-on-read.
2663fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
2664 entries
2665 .iter()
2666 .map(|entry| {
2667 let label = window_label(entry);
2668 if entry.folders.is_empty() {
2669 MenuItem::Label(label)
2670 } else {
2671 MenuItem::Action(MenuAction {
2672 id: format!("focus:{}", entry.key),
2673 label,
2674 enabled: true,
2675 })
2676 }
2677 })
2678 .collect()
2679}
2680
2681/// The tray label for one window: the **main repository** name, then live branch
2682/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
2683/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
2684/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
2685/// that is not a repo falls back to its reported title.
2686fn window_label(entry: &WindowEntry) -> String {
2687 let status = entry
2688 .folders
2689 .first()
2690 .map(|folder| git_status(folder))
2691 .unwrap_or_default();
2692 // Prefer the git-derived main repo so a linked worktree names its parent
2693 // repository rather than its worktree-folder basename.
2694 let name = status
2695 .main_repo
2696 .clone()
2697 .unwrap_or_else(|| display_name(entry));
2698 if let Some(branch) = &status.branch {
2699 let sep = if status.is_worktree {
2700 WORKTREE_SEP
2701 } else {
2702 REPO_SEP
2703 };
2704 return match sync_indicator(status.ahead, status.behind) {
2705 Some(sync) => format!("{name} {sep} {branch} {sync}"),
2706 None => format!("{name} {sep} {branch}"),
2707 };
2708 }
2709 // No git branch (not a repo / detached): fall back to the reported title.
2710 match &entry.title {
2711 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
2712 _ => name,
2713 }
2714}
2715
2716/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
2717/// has no upstream to compare against.
2718fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
2719 match (ahead, behind) {
2720 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
2721 _ => None,
2722 }
2723}
2724
2725/// Well-known absolute locations for the VS Code launcher, tried in order so a
2726/// daemon running under launchd (with a minimal `PATH`) still finds it.
2727const CODE_BINARY_CANDIDATES: &[&str] = &[
2728 "/usr/local/bin/code",
2729 "/opt/homebrew/bin/code",
2730 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
2731 "/usr/bin/code",
2732];
2733
2734/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
2735/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
2736/// with the sessions service's tray "focus" action, which resolves a session to
2737/// its VS Code window folder and opens it through this same guarded launcher.
2738pub(crate) fn focus_window(folder: &Path) -> Result<()> {
2739 focus_window_with(&resolve_code_binary(), folder)
2740}
2741
2742/// Spawns `program` on `folder` after validating the folder. Split out from
2743/// [`focus_window`] so the validation and spawn paths are testable with an
2744/// explicit launcher (no environment or installed-editor dependency).
2745///
2746/// Best-effort and non-blocking: the spawned child is reaped on a detached
2747/// thread so a long-lived daemon does not accumulate zombies one per focus.
2748fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
2749 // The tray path passes an absolute workspace folder, but the socket `open`
2750 // op (#1266) passes an arbitrary client-supplied path, so this guard is a
2751 // real check there, not just an assertion: requiring an absolute path also
2752 // rules out a `-`-leading path being parsed by `code` as a flag.
2753 if !folder.is_absolute() {
2754 bail!(
2755 "refusing to focus a non-absolute folder path: {}",
2756 folder.display()
2757 );
2758 }
2759 if !folder.is_dir() {
2760 bail!("worktree folder no longer exists: {}", folder.display());
2761 }
2762 // Detach the launcher's stdio so its output never interleaves into the
2763 // long-lived daemon's own stdout/stderr (or the test harness's).
2764 let child = Command::new(program)
2765 .arg(folder)
2766 .stdin(Stdio::null())
2767 .stdout(Stdio::null())
2768 .stderr(Stdio::null())
2769 .spawn()
2770 .with_context(|| {
2771 format!(
2772 "failed to launch `{}` to focus {}",
2773 program.display(),
2774 folder.display()
2775 )
2776 })?;
2777 // Reap the child without blocking so it never lingers as a zombie.
2778 std::thread::spawn(move || {
2779 let mut child = child;
2780 let _ = child.wait();
2781 });
2782 Ok(())
2783}
2784
2785/// Resolves the VS Code launcher from the real environment: the
2786/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
2787/// `code` on `PATH`. The pure resolution logic lives in
2788/// [`resolve_code_binary_from`] for testing.
2789fn resolve_code_binary() -> PathBuf {
2790 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
2791}
2792
2793/// Pure launcher resolution: `env_override` wins; otherwise the first existing
2794/// `candidate`; otherwise bare `code`.
2795fn resolve_code_binary_from(
2796 env_override: Option<std::ffi::OsString>,
2797 candidates: &[&str],
2798) -> PathBuf {
2799 if let Some(path) = env_override {
2800 return PathBuf::from(path);
2801 }
2802 for candidate in candidates {
2803 let path = Path::new(candidate);
2804 if path.exists() {
2805 return path.to_path_buf();
2806 }
2807 }
2808 PathBuf::from("code")
2809}
2810
2811// --- Close op (#1277) --------------------------------------------------------
2812
2813/// The `close` op payload: close a worktree's window and (for a linked worktree)
2814/// delete it. Symmetric to `open`, but destructive, so it carries the
2815/// two-phase-confirm and self-close routing fields.
2816#[derive(Debug, Clone, Deserialize)]
2817struct CloseRequest {
2818 /// Absolute path of the target worktree's working directory.
2819 path: PathBuf,
2820 /// The requesting window's key, so a self-close (`requester_key` owns the
2821 /// target) removes-then-replies and lets the extension close its own window,
2822 /// rather than waiting on a window that is blocked awaiting this reply.
2823 #[serde(default)]
2824 requester_key: Option<String>,
2825 /// Whether to **delete** the worktree (linked "Close Worktree") rather than
2826 /// only close its window (main "Close Window"). A delete is refused on the
2827 /// main working tree regardless of this flag.
2828 #[serde(default)]
2829 remove: bool,
2830 /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
2831 /// phase-1, side-effect-free safety check; ignored for `remove:false`.
2832 #[serde(default)]
2833 confirmed: bool,
2834}
2835
2836/// One risk or informational note in a [`SafetyReport`]: a machine-readable
2837/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
2838/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
2839/// commits that survive because the branch is kept).
2840#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2841struct Note {
2842 /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
2843 kind: String,
2844 /// A human-readable one-line explanation for the confirm dialog.
2845 detail: String,
2846}
2847
2848impl Note {
2849 fn new(kind: &str, detail: impl Into<String>) -> Self {
2850 Self {
2851 kind: kind.to_string(),
2852 detail: detail.into(),
2853 }
2854 }
2855}
2856
2857/// The phase-1 safety report the extension reads to decide whether to prompt.
2858/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
2859/// entry → show a modal confirm listing them.
2860#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2861struct SafetyReport {
2862 /// Whether the target is a deletable (linked) worktree at all — `false` for
2863 /// the main working tree, which the daemon never removes.
2864 removable: bool,
2865 /// Whether the target is the repository's main working tree.
2866 is_main: bool,
2867 /// Whether a live VS Code window currently has the target open.
2868 open: bool,
2869 /// The owning window's key, when `open` (the first, for the wait/close).
2870 #[serde(skip_serializing_if = "Option::is_none")]
2871 window_key: Option<String>,
2872 /// How many workspace folders the owning window has — so the extension can
2873 /// warn "this window has N folders open; all will close" (failure mode #10).
2874 window_folder_count: usize,
2875 /// Conditions that would lose data on removal; a non-empty list forces a
2876 /// confirm dialog.
2877 risks: Vec<Note>,
2878 /// Non-blocking context shown for awareness (e.g. unpushed commits that
2879 /// survive because the branch is kept).
2880 info: Vec<Note>,
2881}
2882
2883/// The git-only half of the safety check, before the registry's open-window
2884/// facts are folded in. Pure disk I/O; computed on a blocking thread.
2885#[derive(Debug, Clone, PartialEq, Eq)]
2886struct GitSafety {
2887 is_main: bool,
2888 removable: bool,
2889 risks: Vec<Note>,
2890 info: Vec<Note>,
2891}
2892
2893/// Live windows (key, workspace-folder count) that currently have `path` open,
2894/// matched by canonicalized path so a symlinked or `..`-laden report still
2895/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
2896fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
2897 let target = canonical(path);
2898 entries
2899 .iter()
2900 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
2901 .map(|e| (e.key.clone(), e.folders.len()))
2902 .collect()
2903}
2904
2905/// How long the execute phase waits for a signalled window to close
2906/// (`unregister`) before giving up. Deliberately generous against the ~10s
2907/// heartbeat interval the close directive rides — a window may have just
2908/// heartbeated, so the directive is only picked up on the *next* one — plus the
2909/// window's own close/save latency. The keyed-push responsiveness upgrade
2910/// (#1277 fast-follow) removes this wait entirely.
2911const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
2912
2913/// How often the execute phase re-checks whether the signalled windows have
2914/// unregistered.
2915const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
2916
2917/// Waits up to `timeout` for every window *other than* `requester` that has
2918/// `path` open to unregister (close), polling the live registry every `poll`.
2919/// A window whose `last_seen` has already gone stale is reaped by `list()` and
2920/// so counts as closed. Returns an error naming the still-open windows on
2921/// timeout, so the caller can surface "window did not close" and leave the
2922/// worktree untouched (failure modes #4/#5).
2923async fn await_windows_closed(
2924 registry: &WorktreesRegistry,
2925 path: &Path,
2926 requester: Option<&str>,
2927 timeout: Duration,
2928 poll: Duration,
2929) -> Result<()> {
2930 let deadline = std::time::Instant::now() + timeout;
2931 loop {
2932 // The registry read is cheap CPU, but the path canonicalization in
2933 // `windows_with_path` is disk I/O — do the whole check on a blocking
2934 // thread, never on the async worker.
2935 let entries = registry.list();
2936 let path = path.to_path_buf();
2937 let requester = requester.map(str::to_string);
2938 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
2939 windows_with_path(&entries, &path)
2940 .into_iter()
2941 .map(|(k, _)| k)
2942 .filter(|k| requester.as_deref() != Some(k))
2943 .collect()
2944 })
2945 .await
2946 .unwrap_or_default();
2947
2948 if remaining.is_empty() {
2949 return Ok(());
2950 }
2951 if std::time::Instant::now() >= deadline {
2952 bail!("window(s) did not close in time: {}", remaining.join(", "));
2953 }
2954 tokio::time::sleep(poll).await;
2955 }
2956}
2957
2958/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
2959/// working tree (never removable) and, for a linked worktree, what a removal
2960/// would lose. Best-effort per-check but the overall open must succeed — a path
2961/// that is not a git worktree is a hard error (we refuse to delete an unknown
2962/// directory). A path that no longer exists is treated as an already-removed
2963/// linked worktree so the idempotent execute path can proceed with no dialog.
2964fn git_safety(path: &Path) -> Result<GitSafety> {
2965 if !path.exists() {
2966 return Ok(GitSafety {
2967 is_main: false,
2968 removable: true,
2969 risks: vec![],
2970 info: vec![Note::new("already-removed", "worktree no longer exists")],
2971 });
2972 }
2973 let repo = Repository::open(path)
2974 .with_context(|| format!("not a git worktree: {}", path.display()))?;
2975 // The one structural fact deletability keys off — never the branch name.
2976 if !repo.is_worktree() {
2977 return Ok(GitSafety {
2978 is_main: true,
2979 removable: false,
2980 risks: vec![],
2981 info: vec![Note::new(
2982 "main-working-tree",
2983 "the repository's main working tree is never deleted",
2984 )],
2985 });
2986 }
2987
2988 let mut risks = Vec::new();
2989 let mut info = Vec::new();
2990
2991 let (dirty, untracked) = count_dirty_untracked(&repo);
2992 if dirty > 0 {
2993 risks.push(Note::new(
2994 "dirty",
2995 format!("{dirty} modified tracked file(s) would be lost"),
2996 ));
2997 }
2998 if untracked > 0 {
2999 risks.push(Note::new(
3000 "untracked",
3001 format!("{untracked} untracked file(s) would be lost"),
3002 ));
3003 }
3004
3005 // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
3006 let state = repo.state();
3007 if state != RepositoryState::Clean {
3008 risks.push(Note::new(
3009 "in-progress",
3010 format!("an in-progress {state:?} operation would be lost"),
3011 ));
3012 }
3013
3014 // Commits reachable only from a detached HEAD are GC'd once the worktree —
3015 // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
3016 // or tag) loses nothing, so it is not flagged.
3017 if repo.head_detached().unwrap_or(false) {
3018 let lost = unreachable_commit_count(&repo).unwrap_or(0);
3019 if lost > 0 {
3020 risks.push(Note::new(
3021 "unreachable-commits",
3022 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
3023 ));
3024 }
3025 }
3026
3027 // Unpushed commits on a *named* branch survive: removal never deletes the
3028 // branch. Informational only — it must not block or prompt.
3029 if let Some(ahead) = current_branch_ahead(&repo) {
3030 if ahead > 0 {
3031 info.push(Note::new(
3032 "unpushed",
3033 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
3034 ));
3035 }
3036 }
3037
3038 Ok(GitSafety {
3039 is_main: false,
3040 removable: true,
3041 risks,
3042 info,
3043 })
3044}
3045
3046/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
3047/// staged or unstaged modification (including conflicts and deletions);
3048/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
3049/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
3050/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
3051/// `(0, 0)` rather than sinking the whole safety check.
3052fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
3053 let mut opts = StatusOptions::new();
3054 opts.include_untracked(true)
3055 .recurse_untracked_dirs(true)
3056 .include_ignored(false)
3057 .exclude_submodules(true);
3058 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
3059 return (0, 0);
3060 };
3061 // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
3062 // it is deliberately excluded from this mask).
3063 let tracked = Status::INDEX_NEW
3064 | Status::INDEX_MODIFIED
3065 | Status::INDEX_DELETED
3066 | Status::INDEX_RENAMED
3067 | Status::INDEX_TYPECHANGE
3068 | Status::WT_MODIFIED
3069 | Status::WT_DELETED
3070 | Status::WT_TYPECHANGE
3071 | Status::WT_RENAMED
3072 | Status::CONFLICTED;
3073 let mut dirty = 0;
3074 let mut untracked = 0;
3075 for entry in statuses.iter() {
3076 let s = entry.status();
3077 if s.contains(Status::WT_NEW) {
3078 untracked += 1;
3079 }
3080 if s.intersects(tracked) {
3081 dirty += 1;
3082 }
3083 }
3084 (dirty, untracked)
3085}
3086
3087/// Counts commits reachable from the (detached) HEAD but from no other ref —
3088/// the commits git would garbage-collect once the worktree's HEAD is gone.
3089/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
3090/// skipped (hiding it would hide the very commits we are counting); every real
3091/// branch/tag/remote ref is hidden, so a tip that any branch also points at
3092/// yields `0` (nothing is actually lost).
3093fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
3094 let head_oid = repo.head().ok()?.target()?;
3095 let mut walk = repo.revwalk().ok()?;
3096 walk.push(head_oid).ok()?;
3097 for reference in repo.references().ok()? {
3098 let Ok(reference) = reference else { continue };
3099 // Skip the literal HEAD ref — hiding it would hide the very commits we
3100 // are counting; every real branch/tag/remote ref is hidden below.
3101 if matches!(reference.name(), Ok("HEAD")) {
3102 continue;
3103 }
3104 if let Some(oid) = reference.target() {
3105 let _ = walk.hide(oid);
3106 }
3107 }
3108 Some(walk.flatten().count())
3109}
3110
3111/// Commits the worktree's current branch is ahead of its upstream, or `None`
3112/// when HEAD is detached or the branch tracks no upstream. Reuses
3113/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
3114fn current_branch_ahead(repo: &Repository) -> Option<usize> {
3115 let head = repo.head().ok()?;
3116 if !head.is_branch() {
3117 return None;
3118 }
3119 let branch = git2::Branch::wrap(head);
3120 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
3121}
3122
3123/// Resolves the linked worktree whose working directory canonicalizes to
3124/// `target` to its registered name in `main_repo`. Errors when `target` is not
3125/// one of the repo's worktrees — the defensive guard against removing a path
3126/// that opened as a worktree but is not enumerated. Split out so that guard is
3127/// unit-testable without corrupting git's worktree admin state.
3128fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
3129 let names = main_repo.worktrees()?;
3130 names
3131 .iter()
3132 .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3133 .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3134 .find(|name| {
3135 main_repo
3136 .find_worktree(name)
3137 .is_ok_and(|wt| canonical(wt.path()) == target)
3138 })
3139 .map(str::to_string)
3140 .ok_or_else(|| {
3141 anyhow!(
3142 "worktree {} is not registered in {}",
3143 target.display(),
3144 main_repo.path().display()
3145 )
3146 })
3147}
3148
3149/// Backoff delays between recursive-removal retries (#1315). A concurrent
3150/// writer — a just-closed window's language server (Metals/Bloop) or
3151/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
3152/// create a file between our directory scan and its `rmdir`, making the removal
3153/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
3154/// waits longer, giving the winding-down process time to quiesce. Total wait
3155/// ~2.75s across four retries; the window teardown the caller already waited on
3156/// dominates it.
3157const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
3158 Duration::from_millis(250),
3159 Duration::from_millis(500),
3160 Duration::from_secs(1),
3161 Duration::from_secs(1),
3162];
3163
3164/// Whether `e` is the transient "directory re-populated under us" race we retry
3165/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
3166/// denied, read-only filesystem) we must surface immediately. Matches the raw
3167/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
3168/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
3169fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
3170 matches!(
3171 e.raw_os_error(),
3172 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
3173 )
3174}
3175
3176/// Recursively removes `dir`, retrying on the transient concurrent-writer race
3177/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
3178/// as success. Non-transient errors surface immediately with the original
3179/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
3180/// `spawn_blocking`), so the between-retry `sleep` is fine.
3181fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
3182 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
3183}
3184
3185/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
3186/// Provoking the real race requires a concurrent writer to lose a timing window,
3187/// so only an injected sequence of errors can drive every branch of the loop —
3188/// exhausting the backoff especially — deterministically and without sleeping out
3189/// the production schedule.
3190fn remove_dir_all_retrying_with(
3191 dir: &Path,
3192 backoff: &[Duration],
3193 mut remove: impl FnMut() -> std::io::Result<()>,
3194) -> Result<()> {
3195 let mut backoff = backoff.iter();
3196 loop {
3197 match remove() {
3198 Ok(()) => return Ok(()),
3199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3200 Err(e) => {
3201 if is_transient_rmdir_error(&e) {
3202 if let Some(delay) = backoff.next() {
3203 std::thread::sleep(*delay);
3204 continue;
3205 }
3206 }
3207 return Err(e).with_context(|| {
3208 format!("failed to remove worktree directory {}", dir.display())
3209 });
3210 }
3211 }
3212 }
3213}
3214
3215/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
3216/// still points at an admin directory a prior failed removal already deleted.
3217/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
3218/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
3219/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
3220/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
3221/// opens) and a normal checkout has a `.git` *directory*, so this matches
3222/// neither.
3223fn is_orphaned_worktree(path: &Path) -> bool {
3224 // `read_to_string` fails on a `.git` directory (a normal checkout), so only
3225 // a linked worktree's gitlink file gets past here.
3226 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
3227 return false;
3228 };
3229 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
3230 return false;
3231 };
3232 let admin = Path::new(admin);
3233 // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
3234 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
3235}
3236
3237/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
3238/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
3239/// directory and the admin metadata. Refuses the main working tree (the
3240/// defensive backstop behind the UI gating) and a locked worktree (surfacing
3241/// "unlock first" rather than forcing past the lock). Idempotent: an
3242/// already-removed path is a success.
3243///
3244/// The working tree is removed **first** (retrying to absorb the
3245/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
3246/// This is deliberately the reverse of libgit2's combined
3247/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
3248/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
3249/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
3250/// is already gone). Doing the directory first means a transient failure leaves
3251/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
3252/// the old ordering is detected and its leftover directory cleaned up directly.
3253fn remove_worktree(path: &Path) -> Result<()> {
3254 if !path.exists() {
3255 return Ok(());
3256 }
3257 let repo = match Repository::open(path) {
3258 Ok(repo) => repo,
3259 // Admin metadata already gone (a prior failed removal); git no longer
3260 // tracks this path, so no prune applies — just delete the leftover.
3261 Err(_) if is_orphaned_worktree(path) => return remove_dir_all_retrying(path),
3262 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
3263 };
3264 if !repo.is_worktree() {
3265 bail!(
3266 "refusing to delete the main working tree: {}",
3267 path.display()
3268 );
3269 }
3270 // The Worktree handle lives on the *main* repo (the common dir's parent),
3271 // keyed by name; find it by matching the target path.
3272 let commondir = canonical(repo.commondir());
3273 let main_root = commondir
3274 .parent()
3275 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
3276 .to_path_buf();
3277 // Drop the worktree-scoped handle before we delete its directory.
3278 drop(repo);
3279 let main_repo = Repository::open(&main_root)
3280 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
3281 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
3282 let worktree = main_repo.find_worktree(&name)?;
3283
3284 // Never silently force past a lock (failure mode #6).
3285 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
3286 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
3287 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
3288 }
3289
3290 // Delete the checked-out directory ourselves, retrying past the
3291 // concurrent-writer race (#1315).
3292 remove_dir_all_retrying(path)?;
3293
3294 // The directory is gone; prune only the admin metadata. working_tree(false)
3295 // keeps git2 from re-attempting (and failing on) the now-absent directory;
3296 // valid(true) prunes even though the worktree was valid; locked stays false,
3297 // so a lock (re-checked above) is never forced.
3298 let mut opts = git2::WorktreePruneOptions::new();
3299 opts.valid(true).working_tree(false);
3300 worktree
3301 .prune(Some(&mut opts))
3302 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
3303 Ok(())
3304}
3305
3306#[cfg(test)]
3307#[allow(clippy::unwrap_used, clippy::expect_used)]
3308mod tests {
3309 use super::*;
3310 use crate::test_support::shim::{shim_lock, write_exec_script};
3311 use std::sync::MutexGuard;
3312
3313 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
3314 json!({
3315 "key": key,
3316 "folders": [folder],
3317 "repo": repo,
3318 "title": format!("{key}-title"),
3319 "pid": 1234,
3320 })
3321 }
3322
3323 /// Pulls the `windows` array out of a `list`/`status` payload.
3324 fn windows_of(payload: &Value) -> &Vec<Value> {
3325 payload
3326 .get("windows")
3327 .and_then(Value::as_array)
3328 .expect("windows array")
3329 }
3330
3331 #[tokio::test]
3332 async fn name_and_unknown_op() {
3333 let svc = WorktreesService::new();
3334 assert_eq!(svc.name(), "worktrees");
3335 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
3336 }
3337
3338 #[tokio::test]
3339 async fn handle_routes_ops_and_shapes_payloads() {
3340 let svc = WorktreesService::new();
3341 // Empty to start.
3342 let payload = svc.handle("list", Value::Null).await.unwrap();
3343 assert_eq!(payload, json!({ "windows": [] }));
3344
3345 // register → { ok: true }, then it shows up in list.
3346 let reply = svc
3347 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3348 .await
3349 .unwrap();
3350 assert_eq!(reply, json!({ "ok": true }));
3351 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
3352 assert_eq!(windows.len(), 1);
3353 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
3354 assert!(windows[0].get("last_seen").is_some());
3355
3356 // heartbeat known/unknown.
3357 let known = svc
3358 .handle("heartbeat", json!({ "key": "w1" }))
3359 .await
3360 .unwrap();
3361 assert_eq!(known, json!({ "known": true }));
3362 let unknown = svc
3363 .handle("heartbeat", json!({ "key": "nope" }))
3364 .await
3365 .unwrap();
3366 assert_eq!(unknown, json!({ "known": false }));
3367
3368 // unregister removes, then repeats as a no-op success.
3369 let gone = svc
3370 .handle("unregister", json!({ "key": "w1" }))
3371 .await
3372 .unwrap();
3373 assert_eq!(gone, json!({ "removed": true }));
3374 let again = svc
3375 .handle("unregister", json!({ "key": "w1" }))
3376 .await
3377 .unwrap();
3378 assert_eq!(again, json!({ "removed": false }));
3379 }
3380
3381 #[tokio::test]
3382 async fn handle_rejects_missing_or_empty_key() {
3383 let svc = WorktreesService::new();
3384 // register validates a present, non-blank key.
3385 assert!(svc.handle("register", json!({})).await.is_err());
3386 assert!(svc
3387 .handle("register", json!({ "key": " " }))
3388 .await
3389 .is_err());
3390 // heartbeat/unregister require the key via `require_str`.
3391 assert!(svc.handle("heartbeat", json!({})).await.is_err());
3392 assert!(svc.handle("unregister", json!({})).await.is_err());
3393 }
3394
3395 #[test]
3396 fn display_name_prefers_repo_then_folder_basename() {
3397 let base = WindowEntry {
3398 key: "k".to_string(),
3399 folders: vec![PathBuf::from("/home/me/project")],
3400 repo: Some("my-repo".to_string()),
3401 title: None,
3402 pid: None,
3403 last_seen: Utc::now(),
3404 };
3405 assert_eq!(display_name(&base), "my-repo");
3406
3407 let no_repo = WindowEntry {
3408 repo: None,
3409 ..base.clone()
3410 };
3411 assert_eq!(display_name(&no_repo), "project");
3412
3413 let nothing = WindowEntry {
3414 repo: None,
3415 folders: vec![],
3416 ..base.clone()
3417 };
3418 assert_eq!(display_name(¬hing), "(no folder)");
3419
3420 // A folder with no basename (the filesystem root) falls back to its
3421 // displayed path rather than panicking or yielding an empty name.
3422 let rootish = WindowEntry {
3423 repo: None,
3424 folders: vec![PathBuf::from("/")],
3425 ..base
3426 };
3427 assert_eq!(display_name(&rootish), "/");
3428 }
3429
3430 #[test]
3431 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
3432 let now = Utc::now();
3433 let entries = vec![
3434 // A folderless window has nothing to focus, so it stays a plain
3435 // Label; a title equal to the name collapses to just the name. It
3436 // leads the list so the focus-action lookup below is exercised
3437 // against a leading non-Action item it has to skip.
3438 WindowEntry {
3439 key: "k2".to_string(),
3440 folders: vec![],
3441 repo: Some("solo".to_string()),
3442 title: Some("solo".to_string()),
3443 pid: None,
3444 last_seen: now,
3445 },
3446 // A folder-bearing, non-repo window: one clickable Action whose label
3447 // is the stats line ("name · title", since /tmp is not a git repo).
3448 WindowEntry {
3449 key: "k1".to_string(),
3450 folders: vec![PathBuf::from("/tmp/a")],
3451 repo: Some("repo".to_string()),
3452 title: Some("a branch".to_string()),
3453 pid: None,
3454 last_seen: now,
3455 },
3456 ];
3457 let items = window_menu_items(&entries);
3458 // Exactly one item per window — no duplicate label, no separator.
3459 assert_eq!(items.len(), 2);
3460 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
3461
3462 // The folder-bearing window is a single clickable action carrying the
3463 // stats label (the old label + Focus action, merged).
3464 let action = items
3465 .iter()
3466 .find_map(|i| match i {
3467 MenuItem::Action(a) => Some(a),
3468 _ => None,
3469 })
3470 .expect("a focus action");
3471 assert_eq!(action.id, "focus:k1");
3472 assert_eq!(action.label, "repo · a branch");
3473
3474 // The folderless window is a non-clickable label (not "solo · solo").
3475 let labels: Vec<&str> = items
3476 .iter()
3477 .filter_map(|i| match i {
3478 MenuItem::Label(t) => Some(t.as_str()),
3479 _ => None,
3480 })
3481 .collect();
3482 assert_eq!(labels, vec!["solo"]);
3483 }
3484
3485 #[tokio::test]
3486 async fn menu_and_status_shapes() {
3487 let svc = WorktreesService::new();
3488 // Empty.
3489 let menu = svc.menu();
3490 assert_eq!(menu.title, "Worktrees");
3491 assert!(matches!(
3492 menu.items.first(),
3493 Some(MenuItem::Label(text)) if text == "No open windows"
3494 ));
3495 let status = svc.status().await;
3496 assert_eq!(status.name, "worktrees");
3497 assert!(status.healthy);
3498 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
3499
3500 // Two folder-bearing windows in the same repo, plus one folderless
3501 // window that shares the repo but has nothing for `code` to open.
3502 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3503 .await
3504 .unwrap();
3505 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
3506 .await
3507 .unwrap();
3508 svc.handle(
3509 "register",
3510 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
3511 )
3512 .await
3513 .unwrap();
3514 let status = svc.status().await;
3515 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
3516
3517 let menu = svc.menu();
3518 // One line per window — no separator, no duplicate label.
3519 assert_eq!(menu.items.len(), 3);
3520 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
3521 let action_ids: Vec<&str> = menu
3522 .items
3523 .iter()
3524 .filter_map(|i| match i {
3525 MenuItem::Action(a) => Some(a.id.as_str()),
3526 _ => None,
3527 })
3528 .collect();
3529 // The two folder-bearing windows are clickable; the folderless one is a
3530 // plain Label, so it never yields a focus action.
3531 assert!(action_ids.contains(&"focus:w1"));
3532 assert!(action_ids.contains(&"focus:w2"));
3533 assert!(!action_ids.contains(&"focus:w3"));
3534 }
3535
3536 #[test]
3537 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
3538 // With no tokio runtime, the background task is never spawned, so the
3539 // bare service keeps computing `menu()` inline (what the tests rely on).
3540 let svc = WorktreesService::new();
3541 svc.start_menu_refresh();
3542 assert!(svc.refresh.lock().unwrap().is_none());
3543 }
3544
3545 #[tokio::test]
3546 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
3547 let svc = WorktreesService::new();
3548 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3549 .await
3550 .unwrap();
3551 // Before the task runs, `menu()` computes inline from an empty cache.
3552 assert!(svc.menu_cache.lock().unwrap().is_none());
3553
3554 svc.start_menu_refresh();
3555 // Idempotent: a second call does not start a second task.
3556 svc.start_menu_refresh();
3557
3558 // The task fills the cache off the main thread; poll briefly for it.
3559 let mut filled = false;
3560 for _ in 0..100 {
3561 if svc.menu_cache.lock().unwrap().is_some() {
3562 filled = true;
3563 break;
3564 }
3565 tokio::time::sleep(Duration::from_millis(10)).await;
3566 }
3567 assert!(filled, "background refresh should populate the menu cache");
3568
3569 // `menu()` now serves the cache: one clickable line for the window.
3570 let menu = svc.menu();
3571 assert_eq!(menu.title, "Worktrees");
3572 assert!(menu
3573 .items
3574 .iter()
3575 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
3576
3577 // Shutdown cancels and joins the task, clearing the handle.
3578 svc.shutdown().await;
3579 assert!(svc.refresh.lock().unwrap().is_none());
3580 }
3581
3582 #[tokio::test]
3583 async fn default_constructs_an_empty_service() {
3584 let svc = WorktreesService::default();
3585 let payload = svc.handle("list", Value::Null).await.unwrap();
3586 assert_eq!(payload, json!({ "windows": [] }));
3587 }
3588
3589 // --- Push subscription (#1267) -----------------------------------------
3590
3591 #[tokio::test]
3592 async fn subscribe_streams_only_for_the_subscribe_op() {
3593 let svc = WorktreesService::new();
3594 // The one streaming op yields a stream; every other op (including the
3595 // request/reply worktrees ops) declines, so the server dispatches them
3596 // normally.
3597 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
3598 assert!(svc.subscribe("list", &Value::Null).is_none());
3599 assert!(svc.subscribe("register", &Value::Null).is_none());
3600 assert!(svc.subscribe("bogus", &Value::Null).is_none());
3601 }
3602
3603 #[tokio::test]
3604 async fn subscribe_snapshot_matches_the_tree_op() {
3605 let dir = tempfile::tempdir().unwrap();
3606 let repo = init_repo(dir.path());
3607 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3608 repo.set_head("refs/heads/main").unwrap();
3609
3610 let svc = WorktreesService::new();
3611 let stream = svc
3612 .subscribe("subscribe", &Value::Null)
3613 .expect("subscribe stream");
3614 // No windows yet → no repos derived; the toggle rides along at its
3615 // default (show all).
3616 assert_eq!(
3617 stream.snapshot().await,
3618 json!({ "repos": [], "show_closed": true })
3619 );
3620
3621 // A window opens on the repo → the snapshot carries it, byte-identical to
3622 // what the `tree` op returns for the same registry state.
3623 svc.handle(
3624 "register",
3625 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
3626 )
3627 .await
3628 .unwrap();
3629 let snap = stream.snapshot().await;
3630 let tree = svc.handle("tree", Value::Null).await.unwrap();
3631 assert_eq!(snap, tree);
3632 let repos = snap["repos"].as_array().expect("repos array");
3633 assert_eq!(repos.len(), 1);
3634 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
3635 }
3636
3637 #[tokio::test]
3638 async fn subscribe_changed_wakes_on_register() {
3639 let svc = WorktreesService::new();
3640 let mut stream = svc
3641 .subscribe("subscribe", &Value::Null)
3642 .expect("subscribe stream");
3643 // Idle: `changed()` must not resolve without a registry change.
3644 tokio::select! {
3645 () = stream.changed() => panic!("changed resolved with no registry change"),
3646 () = tokio::time::sleep(Duration::from_millis(50)) => {}
3647 }
3648 // A register bumps the change-notify → `changed()` resolves promptly.
3649 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
3650 .await
3651 .unwrap();
3652 tokio::time::timeout(Duration::from_secs(1), stream.changed())
3653 .await
3654 .expect("changed should resolve after a register");
3655 }
3656
3657 // --- Coalesced tree-snapshot cache (#1303) -----------------------------
3658
3659 #[tokio::test]
3660 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
3661 let reg = Arc::new(WorktreesRegistry::new());
3662 // A long TTL so only the generation gate is exercised here.
3663 let cache = TreeSnapshotCache::with_ttl(
3664 reg,
3665 Arc::new(PrStatusCache::new()),
3666 Duration::from_secs(60),
3667 );
3668 // The first read builds once.
3669 let first = cache.snapshot().await;
3670 assert_eq!(cache.compute_count(), 1);
3671 // Further reads with no registry change and within the TTL reuse the
3672 // cached value — no extra build, byte-identical result.
3673 let second = cache.snapshot().await;
3674 assert_eq!(
3675 cache.compute_count(),
3676 1,
3677 "an unchanged read must not rebuild"
3678 );
3679 assert_eq!(first, second);
3680 }
3681
3682 #[tokio::test]
3683 async fn tree_cache_single_flights_a_read_burst() {
3684 let reg = Arc::new(WorktreesRegistry::new());
3685 let cache = Arc::new(TreeSnapshotCache::with_ttl(
3686 reg,
3687 Arc::new(PrStatusCache::new()),
3688 Duration::from_secs(60),
3689 ));
3690 // A burst of concurrent readers — as N subscriber streams would wake
3691 // together on a change/tick — collapses to exactly one build; the rest
3692 // read the shared result (the acceptance criterion).
3693 let mut handles = Vec::new();
3694 for _ in 0..16 {
3695 let cache = cache.clone();
3696 handles.push(tokio::spawn(async move { cache.snapshot().await }));
3697 }
3698 let mut results = Vec::new();
3699 for handle in handles {
3700 results.push(handle.await.unwrap());
3701 }
3702 assert_eq!(
3703 cache.compute_count(),
3704 1,
3705 "a concurrent read burst must build the tree once"
3706 );
3707 assert!(
3708 results.windows(2).all(|w| w[0] == w[1]),
3709 "every reader must observe the identical snapshot"
3710 );
3711 }
3712
3713 #[tokio::test]
3714 async fn tree_cache_rebuilds_on_registry_change() {
3715 let reg = Arc::new(WorktreesRegistry::new());
3716 let cache = TreeSnapshotCache::with_ttl(
3717 reg.clone(),
3718 Arc::new(PrStatusCache::new()),
3719 Duration::from_secs(60),
3720 );
3721 cache.snapshot().await;
3722 assert_eq!(cache.compute_count(), 1);
3723 // A registry change bumps the generation, so the next read rebuilds even
3724 // though the (long) TTL has not expired — subscribers never see a stale
3725 // visible set.
3726 assert!(reg.set_show_closed(false));
3727 cache.snapshot().await;
3728 assert_eq!(
3729 cache.compute_count(),
3730 2,
3731 "a generation bump must force a rebuild"
3732 );
3733 }
3734
3735 #[tokio::test]
3736 async fn tree_cache_rebuilds_after_ttl_expiry() {
3737 let reg = Arc::new(WorktreesRegistry::new());
3738 // A zero TTL: every read is already past it, so a pure on-disk git change
3739 // still surfaces on the next tick with no registry bump needed.
3740 let cache =
3741 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
3742 cache.snapshot().await;
3743 cache.snapshot().await;
3744 assert_eq!(
3745 cache.compute_count(),
3746 2,
3747 "an expired TTL must force a rebuild each read"
3748 );
3749 }
3750
3751 #[tokio::test]
3752 async fn subscribe_streams_share_one_build_per_generation() {
3753 let svc = WorktreesService::new();
3754 let s1 = svc
3755 .subscribe("subscribe", &Value::Null)
3756 .expect("subscribe stream");
3757 let s2 = svc
3758 .subscribe("subscribe", &Value::Null)
3759 .expect("subscribe stream");
3760 // Two windows' streams sampling the same registry state build the tree
3761 // once, not once per stream (#1303) — they share the service's cache.
3762 let a = s1.snapshot().await;
3763 let b = s2.snapshot().await;
3764 assert_eq!(a, b);
3765 assert_eq!(
3766 svc.tree_cache.compute_count(),
3767 1,
3768 "N streams on one generation must share a single build"
3769 );
3770 }
3771
3772 // --- Show/hide-closed toggle (#1301) -----------------------------------
3773
3774 #[tokio::test]
3775 async fn set_show_closed_toggles_the_snapshot_field() {
3776 let svc = WorktreesService::new();
3777 // The snapshot carries the toggle; it defaults to show-all.
3778 assert_eq!(
3779 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
3780 json!(true)
3781 );
3782 // Setting it flips the field the next snapshot reports.
3783 let reply = svc
3784 .handle("set-show-closed", json!({ "show_closed": false }))
3785 .await
3786 .unwrap();
3787 assert_eq!(reply, json!({ "ok": true }));
3788 assert_eq!(
3789 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
3790 json!(false)
3791 );
3792 }
3793
3794 #[tokio::test]
3795 async fn set_show_closed_rejects_a_non_boolean_payload() {
3796 let svc = WorktreesService::new();
3797 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
3798 assert!(svc
3799 .handle("set-show-closed", json!({ "show_closed": "yes" }))
3800 .await
3801 .is_err());
3802 }
3803
3804 #[tokio::test]
3805 async fn set_show_closed_wakes_the_subscription() {
3806 let svc = WorktreesService::new();
3807 let mut stream = svc
3808 .subscribe("subscribe", &Value::Null)
3809 .expect("subscribe stream");
3810 // A real flip bumps the change-notify → `changed()` resolves promptly.
3811 svc.handle("set-show-closed", json!({ "show_closed": false }))
3812 .await
3813 .unwrap();
3814 tokio::time::timeout(Duration::from_secs(1), stream.changed())
3815 .await
3816 .expect("changed should resolve after a toggle flip");
3817 // The pushed snapshot now reflects the new toggle.
3818 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
3819 }
3820
3821 #[tokio::test]
3822 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
3823 // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
3824 // drops it (skip-if-false), so the extension colours the icon off the flag.
3825 let dir = tempfile::tempdir().unwrap();
3826 github_repo(dir.path());
3827 let svc = WorktreesService::new();
3828 svc.handle(
3829 "register",
3830 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3831 )
3832 .await
3833 .unwrap();
3834
3835 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
3836 assert!(
3837 repo.get("polling_enabled").is_none(),
3838 "default off omits the flag: {repo:?}"
3839 );
3840
3841 let reply = svc
3842 .handle(
3843 "set-polling",
3844 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
3845 )
3846 .await
3847 .unwrap();
3848 assert_eq!(reply, json!({ "ok": true }));
3849 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
3850 assert_eq!(repo["polling_enabled"], json!(true));
3851
3852 svc.handle(
3853 "set-polling",
3854 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
3855 )
3856 .await
3857 .unwrap();
3858 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
3859 assert!(repo.get("polling_enabled").is_none());
3860 }
3861
3862 #[tokio::test]
3863 async fn set_polling_rejects_missing_or_empty_fields() {
3864 let svc = WorktreesService::new();
3865 // Missing `enabled`.
3866 assert!(svc
3867 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
3868 .await
3869 .is_err());
3870 // Missing `owner`/`name`.
3871 assert!(svc
3872 .handle("set-polling", json!({ "enabled": true }))
3873 .await
3874 .is_err());
3875 // Blank `owner`/`name`.
3876 assert!(svc
3877 .handle(
3878 "set-polling",
3879 json!({ "owner": " ", "name": "n", "enabled": true })
3880 )
3881 .await
3882 .is_err());
3883 }
3884
3885 #[tokio::test]
3886 async fn set_polling_wakes_the_subscription() {
3887 let dir = tempfile::tempdir().unwrap();
3888 github_repo(dir.path());
3889 let svc = WorktreesService::new();
3890 svc.handle(
3891 "register",
3892 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3893 )
3894 .await
3895 .unwrap();
3896 let mut stream = svc
3897 .subscribe("subscribe", &Value::Null)
3898 .expect("subscribe stream");
3899 svc.handle(
3900 "set-polling",
3901 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
3902 )
3903 .await
3904 .unwrap();
3905 tokio::time::timeout(Duration::from_secs(1), stream.changed())
3906 .await
3907 .expect("changed should resolve after enabling a repo");
3908 let repo = repos_of(&stream.snapshot().await)[0].clone();
3909 assert_eq!(repo["polling_enabled"], json!(true));
3910 }
3911
3912 #[tokio::test]
3913 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
3914 // The "drop existing badges immediately" requirement (#1376), done
3915 // daemon-side: the fold skips a not-polled repo, so a disable strips the
3916 // badge on the very next snapshot rather than waiting for a poll.
3917 let dir = tempfile::tempdir().unwrap();
3918 let repo = github_repo(dir.path());
3919 let head = repo.head().unwrap().target().unwrap().to_string();
3920 let svc = WorktreesService::new();
3921 svc.handle(
3922 "register",
3923 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3924 )
3925 .await
3926 .unwrap();
3927 svc.registry.set_polling("rust-works", "omni-dev", true);
3928
3929 let mut badges = HashMap::new();
3930 badges.insert(
3931 PrTarget {
3932 owner: "rust-works".into(),
3933 name: "omni-dev".into(),
3934 branch: "main".into(),
3935 },
3936 pr(pending_badge(7, &head)),
3937 );
3938 svc.pr_cache.replace(badges);
3939
3940 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3941 assert_eq!(wt["pr"]["number"], json!(7));
3942
3943 svc.handle(
3944 "set-polling",
3945 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
3946 )
3947 .await
3948 .unwrap();
3949 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
3950 assert!(
3951 wt.get("pr").is_none(),
3952 "a disabled repo must carry no badge: {wt:?}"
3953 );
3954 }
3955
3956 #[tokio::test]
3957 async fn an_expired_lease_drops_the_flag_and_badges() {
3958 // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
3959 // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
3960 // poller would no longer watch it — all reaped on read, no timer.
3961 let dir = tempfile::tempdir().unwrap();
3962 let repo = github_repo(dir.path());
3963 let head = repo.head().unwrap().target().unwrap().to_string();
3964 let svc = WorktreesService::new();
3965 svc.handle(
3966 "register",
3967 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3968 )
3969 .await
3970 .unwrap();
3971 svc.registry.set_polling("rust-works", "omni-dev", true);
3972 let mut badges = HashMap::new();
3973 badges.insert(
3974 PrTarget {
3975 owner: "rust-works".into(),
3976 name: "omni-dev".into(),
3977 branch: "main".into(),
3978 },
3979 pr(pending_badge(7, &head)),
3980 );
3981 svc.pr_cache.replace(badges);
3982
3983 // Leased: flag stamped, badge folded, and the poller would watch it.
3984 let snap = svc.handle("tree", Value::Null).await.unwrap();
3985 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
3986 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
3987 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
3988
3989 // Force the lease into the past — as 15 minutes elapsing would.
3990 svc.registry.set_polling_expiry(
3991 "rust-works",
3992 "omni-dev",
3993 Utc::now() - chrono::Duration::minutes(1),
3994 );
3995
3996 let snap = svc.handle("tree", Value::Null).await.unwrap();
3997 let repo0 = &repos_of(&snap)[0];
3998 assert!(
3999 repo0.get("polling_enabled").is_none(),
4000 "expired lease drops the flag: {repo0:?}"
4001 );
4002 assert!(
4003 repo0["worktrees"][0].get("pr").is_none(),
4004 "expired lease drops the badge"
4005 );
4006 assert!(
4007 pr_targets_from_snapshot(&snap).is_empty(),
4008 "the poller no longer watches an expired repo"
4009 );
4010 }
4011
4012 #[tokio::test]
4013 async fn polling_prefs_persist_across_reloads_with_0600() {
4014 // The enable set survives a daemon restart (#1376): a change writes the
4015 // `0600` file, and a fresh service seeded from it comes up enabled.
4016 let dir = tempfile::tempdir().unwrap();
4017 let prefs = dir.path().join("worktrees-polling.json");
4018
4019 let svc = WorktreesService::new();
4020 svc.load_polling_prefs(prefs.clone());
4021 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
4022 svc.handle(
4023 "set-polling",
4024 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
4025 )
4026 .await
4027 .unwrap();
4028 assert!(prefs.exists());
4029 #[cfg(unix)]
4030 {
4031 use std::os::unix::fs::PermissionsExt;
4032 assert_eq!(
4033 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
4034 0o600
4035 );
4036 }
4037
4038 // A new service reloads the enabled set from that file.
4039 let svc2 = WorktreesService::new();
4040 svc2.load_polling_prefs(prefs.clone());
4041 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
4042
4043 // Disabling rewrites the file, so the next reload has nothing enabled.
4044 svc2.handle(
4045 "set-polling",
4046 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
4047 )
4048 .await
4049 .unwrap();
4050 let svc3 = WorktreesService::new();
4051 svc3.load_polling_prefs(prefs);
4052 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
4053 }
4054
4055 #[test]
4056 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
4057 // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
4058 // path is logged and treated as "nothing enabled" rather than wedging the
4059 // service. A missing file is already the first-run default (covered by the
4060 // persistence round-trip above); this exercises the two error branches.
4061 let dir = tempfile::tempdir().unwrap();
4062
4063 // Corrupt JSON — the parse error is swallowed, nothing is enabled.
4064 let corrupt = dir.path().join("worktrees-polling.json");
4065 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
4066 let svc = WorktreesService::new();
4067 svc.load_polling_prefs(corrupt);
4068 assert!(svc.registry.enabled_polling_repos().is_empty());
4069
4070 // A directory at the path — the (non-NotFound) read error is swallowed too.
4071 let as_dir = dir.path().join("is-a-directory");
4072 std::fs::create_dir(&as_dir).unwrap();
4073 let svc2 = WorktreesService::new();
4074 svc2.load_polling_prefs(as_dir);
4075 assert!(svc2.registry.enabled_polling_repos().is_empty());
4076 }
4077
4078 #[tokio::test]
4079 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
4080 // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
4081 // the user has not enabled polling for it — so the poller spawns no `gh`.
4082 let dir = tempfile::tempdir().unwrap();
4083 github_repo(dir.path());
4084 let bin_dir = tempfile::tempdir().unwrap();
4085 let marker = bin_dir.path().join("spawned");
4086 let fake = bin_dir.path().join("fake-gh");
4087 std::fs::write(
4088 &fake,
4089 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
4090 )
4091 .unwrap();
4092 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
4093 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
4094 std::fs::set_permissions(&fake, perms).unwrap();
4095
4096 let svc = WorktreesService::new();
4097 svc.handle(
4098 "register",
4099 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
4100 )
4101 .await
4102 .unwrap();
4103 // Deliberately NOT enabling polling for the repo.
4104 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
4105 tokio::time::sleep(Duration::from_millis(200)).await;
4106 svc.shutdown().await;
4107 assert!(
4108 !marker.exists(),
4109 "a registered-but-not-enabled repo must drive zero gh"
4110 );
4111 }
4112
4113 #[tokio::test]
4114 async fn menu_action_rejects_unknown_and_missing_window() {
4115 let svc = WorktreesService::new();
4116 assert!(svc.menu_action("bogus").await.is_err());
4117 // A focus for a key with no registration errors rather than spawning.
4118 assert!(svc.menu_action("focus:nope").await.is_err());
4119 svc.shutdown().await;
4120 }
4121
4122 /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
4123 /// variable (via `resolve_code_binary` → `focus_window`) —
4124 /// `menu_action_focus_resolves_folder_and_spawns` and
4125 /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
4126 /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
4127 /// transient overlap under the harness's test parallelism is benign.
4128 struct VscodeBinGuard(Option<std::ffi::OsString>);
4129 impl Drop for VscodeBinGuard {
4130 fn drop(&mut self) {
4131 match self.0.take() {
4132 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
4133 None => std::env::remove_var(VSCODE_BIN_ENV),
4134 }
4135 }
4136 }
4137
4138 #[tokio::test]
4139 async fn menu_action_focus_resolves_folder_and_spawns() {
4140 let dir = tempfile::tempdir().unwrap();
4141 let svc = WorktreesService::new();
4142 svc.handle(
4143 "register",
4144 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4145 )
4146 .await
4147 .unwrap();
4148
4149 // Point the launcher at a harmless binary so the spawn deterministically
4150 // succeeds and the focus path returns Ok.
4151 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
4152 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
4153 svc.menu_action("focus:w1").await.unwrap();
4154 }
4155
4156 #[tokio::test]
4157 async fn open_rejects_missing_relative_or_nonexistent_path() {
4158 let svc = WorktreesService::new();
4159 // A missing `path` is a payload error.
4160 assert!(svc.handle("open", json!({})).await.is_err());
4161 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
4162 // A relative path is rejected before any spawn — this is also what
4163 // blocks a `-`-leading argument from reaching `code` as a flag.
4164 assert!(svc
4165 .handle("open", json!({ "path": "relative/dir" }))
4166 .await
4167 .is_err());
4168 assert!(svc
4169 .handle("open", json!({ "path": "-flag" }))
4170 .await
4171 .is_err());
4172 // An absolute path that does not exist is rejected before any spawn, so
4173 // no launcher is needed for these guard cases.
4174 assert!(svc
4175 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
4176 .await
4177 .is_err());
4178 svc.shutdown().await;
4179 }
4180
4181 #[tokio::test]
4182 async fn open_focuses_an_existing_absolute_dir() {
4183 let dir = tempfile::tempdir().unwrap();
4184 let svc = WorktreesService::new();
4185 // Pin the launcher to a harmless binary so the spawn deterministically
4186 // succeeds whether or not `code` is installed. Unlike the tray `focus`
4187 // path, `open` takes the folder straight from the payload — no prior
4188 // `register` is required.
4189 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
4190 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
4191 let reply = svc
4192 .handle("open", json!({ "path": dir.path() }))
4193 .await
4194 .unwrap();
4195 assert_eq!(reply, json!({ "ok": true }));
4196 svc.shutdown().await;
4197 }
4198
4199 #[test]
4200 fn focus_window_with_validates_folder_then_spawns() {
4201 let dir = tempfile::tempdir().unwrap();
4202 // Non-absolute and missing-directory folders are rejected before spawn.
4203 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
4204 assert!(
4205 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
4206 );
4207 // A valid absolute directory spawns the launcher successfully.
4208 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
4209 // A missing launcher surfaces the spawn error (with context), not Ok.
4210 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
4211 }
4212
4213 #[test]
4214 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
4215 // Env override wins outright.
4216 assert_eq!(
4217 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
4218 PathBuf::from("/custom/code")
4219 );
4220 // No override: the first existing candidate is chosen.
4221 let existing = tempfile::NamedTempFile::new().unwrap();
4222 let existing_path = existing.path().to_str().unwrap();
4223 assert_eq!(
4224 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
4225 PathBuf::from(existing_path)
4226 );
4227 // Nothing exists: fall back to bare `code` on PATH.
4228 assert_eq!(
4229 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
4230 PathBuf::from("code")
4231 );
4232 // The real-env wrapper resolves without panicking.
4233 let _ = resolve_code_binary();
4234 }
4235
4236 // --- Git enrichment (#1186) --------------------------------------------
4237
4238 /// Initializes a fresh repo with a deterministic identity so `commit()`
4239 /// works without depending on a global git config.
4240 fn init_repo(dir: &Path) -> Repository {
4241 let repo = Repository::init(dir).unwrap();
4242 let mut cfg = repo.config().unwrap();
4243 cfg.set_str("user.name", "Test").unwrap();
4244 cfg.set_str("user.email", "test@example.com").unwrap();
4245 repo
4246 }
4247
4248 /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
4249 /// optionally moving `refname` to it, and returns its oid.
4250 fn empty_commit(
4251 repo: &Repository,
4252 refname: Option<&str>,
4253 parents: &[&git2::Commit<'_>],
4254 msg: &str,
4255 ) -> git2::Oid {
4256 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
4257 let tree = repo
4258 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
4259 .unwrap();
4260 repo.commit(refname, &sig, &sig, msg, &tree, parents)
4261 .unwrap()
4262 }
4263
4264 /// Commits `content` as file `name` onto `refname`, chaining off its current
4265 /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
4266 /// the file is checked out into a worktree and can then be modified to
4267 /// produce a dirty (tracked) status.
4268 fn commit_file(
4269 repo: &Repository,
4270 refname: &str,
4271 name: &str,
4272 content: &[u8],
4273 msg: &str,
4274 ) -> git2::Oid {
4275 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
4276 let blob = repo.blob(content).unwrap();
4277 let mut builder = repo.treebuilder(None).unwrap();
4278 builder.insert(name, blob, 0o100_644).unwrap();
4279 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
4280 let parent = repo
4281 .refname_to_id(refname)
4282 .ok()
4283 .and_then(|oid| repo.find_commit(oid).ok());
4284 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
4285 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
4286 .unwrap()
4287 }
4288
4289 /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
4290 /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
4291 fn diverging_repo(dir: &Path) -> Repository {
4292 let repo = init_repo(dir);
4293 // A: the shared base on `main`.
4294 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4295 let a_commit = repo.find_commit(a).unwrap();
4296 // origin/main diverges to C, a sibling of the local tip.
4297 let c = empty_commit(&repo, None, &[&a_commit], "C");
4298 repo.reference("refs/remotes/origin/main", c, true, "origin main")
4299 .unwrap();
4300 // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
4301 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
4302 // Release the commit's borrow of `repo` so it can be returned.
4303 drop(a_commit);
4304 repo.set_head("refs/heads/main").unwrap();
4305 // Configure the tracking relationship so `upstream()` resolves.
4306 let mut cfg = repo.config().unwrap();
4307 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
4308 .unwrap();
4309 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
4310 .unwrap();
4311 cfg.set_str("branch.main.remote", "origin").unwrap();
4312 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
4313 repo
4314 }
4315
4316 #[test]
4317 fn git_status_reads_branch_and_ahead_behind() {
4318 let dir = tempfile::tempdir().unwrap();
4319 let _repo = diverging_repo(dir.path());
4320 let status = git_status(dir.path());
4321 assert_eq!(status.branch.as_deref(), Some("main"));
4322 assert_eq!(status.ahead, Some(1));
4323 assert_eq!(status.behind, Some(1));
4324 // A normal checkout names itself and is not flagged a worktree.
4325 assert_eq!(
4326 status.main_repo.as_deref(),
4327 dir.path().file_name().and_then(|n| n.to_str())
4328 );
4329 assert!(!status.is_worktree);
4330 }
4331
4332 #[test]
4333 fn git_status_empty_repo_is_unborn() {
4334 // A repo with no commits has an unborn HEAD, so `head()` errors and the
4335 // branch/sync fields stay empty rather than panicking — but the repo
4336 // identity is still resolved from the common dir.
4337 let dir = tempfile::tempdir().unwrap();
4338 init_repo(dir.path());
4339 let status = git_status(dir.path());
4340 assert_eq!(status.branch, None);
4341 // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
4342 assert_eq!(status.head_sha, None);
4343 assert_eq!(status.ahead, None);
4344 assert_eq!(status.behind, None);
4345 assert_eq!(
4346 status.main_repo.as_deref(),
4347 dir.path().file_name().and_then(|n| n.to_str())
4348 );
4349 assert!(!status.is_worktree);
4350 }
4351
4352 #[test]
4353 fn git_status_no_upstream_reports_branch_only() {
4354 let dir = tempfile::tempdir().unwrap();
4355 let repo = init_repo(dir.path());
4356 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4357 repo.set_head("refs/heads/main").unwrap();
4358 let status = git_status(dir.path());
4359 assert_eq!(status.branch.as_deref(), Some("main"));
4360 // No upstream → ahead/behind stay absent rather than zero.
4361 assert_eq!(status.ahead, None);
4362 assert_eq!(status.behind, None);
4363 // …and so does the upstream SHA, so such a branch still renders with no
4364 // sync indicator at all (#1344).
4365 assert_eq!(status.upstream_sha, None);
4366 }
4367
4368 #[test]
4369 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
4370 // A plain directory that is not a git repo yields nothing at all.
4371 let plain = tempfile::tempdir().unwrap();
4372 assert_eq!(git_status(plain.path()), GitStatus::default());
4373
4374 // A detached HEAD reports no branch (and thus no sync), but the repo
4375 // identity is still resolved from the common dir.
4376 let dir = tempfile::tempdir().unwrap();
4377 let repo = init_repo(dir.path());
4378 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4379 repo.set_head_detached(a).unwrap();
4380 let status = git_status(dir.path());
4381 assert_eq!(status.branch, None);
4382 // A detached HEAD has no branch but *does* have a commit — the SHA is
4383 // resolved before the branch filter, so it survives here (#1337).
4384 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
4385 assert_eq!(status.ahead, None);
4386 assert_eq!(status.behind, None);
4387 // A detached HEAD has no branch, so there is no upstream to resolve
4388 // either — the branch filter returns before the wrap (#1344).
4389 assert_eq!(status.upstream_sha, None);
4390 assert_eq!(
4391 status.main_repo.as_deref(),
4392 dir.path().file_name().and_then(|n| n.to_str())
4393 );
4394 assert!(!status.is_worktree);
4395 }
4396
4397 // --- Lazy ahead/behind (#1306) -----------------------------------------
4398
4399 #[test]
4400 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
4401 // The same repo `git_status` reports 1/1 for. The cheap variant used by
4402 // the streamed tree snapshot still reads the branch and repo identity, but
4403 // leaves ahead/behind absent — divergence is now lazy (#1306).
4404 let dir = tempfile::tempdir().unwrap();
4405 let repo = diverging_repo(dir.path());
4406 let status = git_status_cheap(dir.path());
4407 assert_eq!(status.branch.as_deref(), Some("main"));
4408 assert_eq!(status.ahead, None);
4409 assert_eq!(status.behind, None);
4410 assert_eq!(
4411 status.main_repo.as_deref(),
4412 dir.path().file_name().and_then(|n| n.to_str())
4413 );
4414 // The SHA rides the *cheap* path deliberately: it is a refs read, not a
4415 // revwalk, and it is what makes a new commit a snapshot delta (#1337).
4416 let head = repo.head().unwrap().target().unwrap();
4417 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
4418 }
4419
4420 // --- HEAD SHA on the snapshot (#1337) ----------------------------------
4421
4422 #[test]
4423 fn git_status_head_sha_tracks_new_commits() {
4424 // The regression #1337 turns on: a commit must change the status the
4425 // snapshot is built from. Before the SHA rode the payload, committing
4426 // changed nothing on the wire, the server's diff dropped the identical
4427 // snapshot, and no client re-rendered — so a badge computed for the old
4428 // head survived the push that invalidated it.
4429 let dir = tempfile::tempdir().unwrap();
4430 let repo = init_repo(dir.path());
4431 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4432 repo.set_head("refs/heads/main").unwrap();
4433 let before = git_status_cheap(dir.path());
4434 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
4435
4436 let head = repo.find_commit(a).unwrap();
4437 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
4438 let after = git_status_cheap(dir.path());
4439 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
4440 assert_ne!(before.head_sha, after.head_sha);
4441 // The branch is unchanged — the SHA is the *only* thing that moved, which
4442 // is exactly why its absence made the push invisible.
4443 assert_eq!(before.branch, after.branch);
4444 }
4445
4446 // --- Upstream SHA on the snapshot (#1344) ------------------------------
4447
4448 /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
4449 /// does, and all of what it does: the local branch and HEAD do not move. Lets
4450 /// these tests exercise a push with no network and no second repo.
4451 fn simulate_push(repo: &Repository, oid: git2::Oid) {
4452 repo.reference("refs/remotes/origin/main", oid, true, "push")
4453 .unwrap();
4454 }
4455
4456 #[test]
4457 fn git_status_upstream_sha_tracks_a_push() {
4458 // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
4459 // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
4460 // remote-tracking ref, so before this field rode the payload every wire
4461 // field was byte-identical across the push, the server's diff dropped the
4462 // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
4463 // never re-asked — the row showed `↑1 ↓0` forever.
4464 let dir = tempfile::tempdir().unwrap();
4465 let repo = diverging_repo(dir.path());
4466 let before = git_status(dir.path());
4467 assert_eq!(before.ahead, Some(1));
4468 assert_eq!(before.behind, Some(1));
4469
4470 let head = repo.head().unwrap().target().unwrap();
4471 simulate_push(&repo, head);
4472 let after = git_status(dir.path());
4473
4474 // The upstream now names the pushed commit, and the counts agree.
4475 assert_eq!(
4476 after.upstream_sha.as_deref(),
4477 Some(head.to_string().as_str())
4478 );
4479 assert_ne!(before.upstream_sha, after.upstream_sha);
4480 assert_eq!(after.ahead, Some(0));
4481 assert_eq!(after.behind, Some(0));
4482 // Nothing else moved — which is the whole point. A push leaves the branch
4483 // and the local head exactly where they were, so `upstream_sha` is the
4484 // only signal a client could possibly notice.
4485 assert_eq!(before.branch, after.branch);
4486 assert_eq!(before.head_sha, after.head_sha);
4487 }
4488
4489 #[test]
4490 fn git_status_cheap_reports_upstream_sha() {
4491 // The crux: the field has to ride the *cheap* path, since that is the one
4492 // the streamed snapshot is built from. Costing a config lookup and a refs
4493 // read — no revwalk — it clears the bar #1306 set, unlike the divergence
4494 // walk still absent here.
4495 let dir = tempfile::tempdir().unwrap();
4496 let repo = diverging_repo(dir.path());
4497 let status = git_status_cheap(dir.path());
4498 let upstream = repo
4499 .find_branch("origin/main", git2::BranchType::Remote)
4500 .unwrap()
4501 .get()
4502 .target()
4503 .unwrap();
4504 assert_eq!(
4505 status.upstream_sha.as_deref(),
4506 Some(upstream.to_string().as_str())
4507 );
4508 assert_eq!(status.ahead, None);
4509 assert_eq!(status.behind, None);
4510 }
4511
4512 #[test]
4513 fn folder_ahead_behind_computes_divergence_and_degrades() {
4514 // A diverging tracking branch → the on-demand walk reports (ahead, behind).
4515 let dir = tempfile::tempdir().unwrap();
4516 let _repo = diverging_repo(dir.path());
4517 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
4518
4519 // A branch with no upstream → None (the tree renders no sync indicator).
4520 let no_up = tempfile::tempdir().unwrap();
4521 let repo = init_repo(no_up.path());
4522 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4523 repo.set_head("refs/heads/main").unwrap();
4524 assert_eq!(folder_ahead_behind(no_up.path()), None);
4525
4526 // A detached HEAD and a plain (non-repo) directory → None.
4527 let detached = tempfile::tempdir().unwrap();
4528 let drepo = init_repo(detached.path());
4529 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
4530 drepo.set_head_detached(a).unwrap();
4531 assert_eq!(folder_ahead_behind(detached.path()), None);
4532 let plain = tempfile::tempdir().unwrap();
4533 assert_eq!(folder_ahead_behind(plain.path()), None);
4534 }
4535
4536 #[tokio::test]
4537 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
4538 let diverging = tempfile::tempdir().unwrap();
4539 let _d = diverging_repo(diverging.path());
4540 let no_up = tempfile::tempdir().unwrap();
4541 let repo = init_repo(no_up.path());
4542 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4543 repo.set_head("refs/heads/main").unwrap();
4544
4545 let svc = WorktreesService::new();
4546 let diverging_path = diverging.path().display().to_string();
4547 let no_up_path = no_up.path().display().to_string();
4548 let reply = svc
4549 .handle(
4550 "ahead-behind",
4551 json!({ "paths": [&diverging_path, &no_up_path] }),
4552 )
4553 .await
4554 .unwrap();
4555 let results = reply.get("results").unwrap();
4556 // The diverging worktree carries its counts, keyed by the requested path.
4557 let d = results.get(diverging_path.as_str()).unwrap();
4558 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
4559 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
4560 // The no-upstream worktree is omitted entirely, not reported as zero.
4561 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
4562
4563 // A missing/empty `paths` list yields an empty results object, not an error.
4564 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
4565 assert_eq!(empty.get("results"), Some(&json!({})));
4566 }
4567
4568 #[tokio::test]
4569 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
4570 // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
4571 let dir = tempfile::tempdir().unwrap();
4572 let _repo = diverging_repo(dir.path());
4573 let svc = WorktreesService::new();
4574 svc.handle(
4575 "register",
4576 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4577 )
4578 .await
4579 .unwrap();
4580
4581 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4582 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
4583 let main_wt = &worktrees[0];
4584 // The cheap parts are present, but divergence is not — it is fetched
4585 // lazily via the `ahead-behind` op (#1306).
4586 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
4587 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
4588 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
4589 }
4590
4591 #[tokio::test]
4592 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
4593 // The end-to-end shape of the #1337 freshness fix. The server pushes a
4594 // snapshot only when it differs from the last one
4595 // (`server.rs`: `if snap != last`), so anything invisible on the wire
4596 // cannot trigger a re-render. Committing must therefore move the payload.
4597 let dir = tempfile::tempdir().unwrap();
4598 let repo = init_repo(dir.path());
4599 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4600 repo.set_head("refs/heads/main").unwrap();
4601
4602 let svc = WorktreesService::new();
4603 svc.handle(
4604 "register",
4605 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4606 )
4607 .await
4608 .unwrap();
4609
4610 let before = svc.handle("tree", Value::Null).await.unwrap();
4611 let wt = &repos_of(&before)[0]["worktrees"][0];
4612 assert_eq!(
4613 wt.get("head_sha").and_then(Value::as_str),
4614 Some(a.to_string().as_str())
4615 );
4616
4617 // Commit again: same branch, same paths, same open windows — pre-#1337 the
4618 // snapshot was byte-identical here and the push was dropped.
4619 let head = repo.find_commit(a).unwrap();
4620 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
4621 let after = svc.handle("tree", Value::Null).await.unwrap();
4622 assert_eq!(
4623 repos_of(&after)[0]["worktrees"][0]
4624 .get("head_sha")
4625 .and_then(Value::as_str),
4626 Some(b.to_string().as_str())
4627 );
4628 assert_ne!(before, after, "a commit must be a visible snapshot delta");
4629 }
4630
4631 #[tokio::test]
4632 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
4633 // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
4634 // matching the payload's `skip_serializing_if` convention.
4635 let dir = tempfile::tempdir().unwrap();
4636 init_repo(dir.path());
4637 let svc = WorktreesService::new();
4638 svc.handle(
4639 "register",
4640 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4641 )
4642 .await
4643 .unwrap();
4644 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4645 assert!(wt.get("head_sha").is_none(), "{wt:?}");
4646 }
4647
4648 // --- Upstream SHA on the snapshot (#1344) ------------------------------
4649
4650 #[tokio::test]
4651 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
4652 // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
4653 // moves neither the branch nor the local head, so `upstream_sha` is the
4654 // only field that can carry the news. Without it the snapshot serialised
4655 // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
4656 // window re-rendered, and the lazy ahead/behind was never re-fetched.
4657 let dir = tempfile::tempdir().unwrap();
4658 let repo = diverging_repo(dir.path());
4659 let svc = WorktreesService::new();
4660 svc.handle(
4661 "register",
4662 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4663 )
4664 .await
4665 .unwrap();
4666
4667 let before = svc.handle("tree", Value::Null).await.unwrap();
4668 let head = repo.head().unwrap().target().unwrap();
4669 assert_ne!(
4670 repos_of(&before)[0]["worktrees"][0]
4671 .get("upstream_sha")
4672 .and_then(Value::as_str),
4673 Some(head.to_string().as_str()),
4674 "the fixture must start un-pushed for this to prove anything"
4675 );
4676
4677 // Push: only `refs/remotes/origin/main` moves.
4678 simulate_push(&repo, head);
4679 let after = svc.handle("tree", Value::Null).await.unwrap();
4680 let wt = &repos_of(&after)[0]["worktrees"][0];
4681 assert_eq!(
4682 wt.get("upstream_sha").and_then(Value::as_str),
4683 Some(head.to_string().as_str())
4684 );
4685 // The head and branch are untouched across the push — so this delta rests
4686 // entirely on `upstream_sha`.
4687 assert_eq!(
4688 wt.get("head_sha").and_then(Value::as_str),
4689 repos_of(&before)[0]["worktrees"][0]
4690 .get("head_sha")
4691 .and_then(Value::as_str)
4692 );
4693 assert_ne!(before, after, "a push must be a visible snapshot delta");
4694 }
4695
4696 #[tokio::test]
4697 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
4698 // Wire-compat, and the no-regression case: a branch tracking nothing sends
4699 // no key at all rather than a null, so an older client sees exactly the
4700 // payload it saw before and still renders no sync indicator.
4701 let dir = tempfile::tempdir().unwrap();
4702 let repo = init_repo(dir.path());
4703 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4704 repo.set_head("refs/heads/main").unwrap();
4705 let svc = WorktreesService::new();
4706 svc.handle(
4707 "register",
4708 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4709 )
4710 .await
4711 .unwrap();
4712 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4713 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
4714 // The head still rides, so this is specifically the upstream degrading.
4715 assert!(wt.get("head_sha").is_some(), "{wt:?}");
4716 }
4717
4718 // --- PR badge poller (#1337) -------------------------------------------
4719
4720 /// Writes an executable stub that ignores its arguments and prints `stdout`,
4721 /// standing in for `gh api graphql` so the poll loop is exercised offline.
4722 /// Returns the shim lock alongside the path: the caller **must** hold the
4723 /// guard until the poller has finished exec'ing the stub. Writing an
4724 /// executable and then `execve`ing it races every other thread that forks —
4725 /// the child inherits the still-open writable FD and the exec fails
4726 /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
4727 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
4728 let guard = shim_lock();
4729 let path = dir.join("fake-gh");
4730 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
4731 (path, guard)
4732 }
4733
4734 /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
4735 /// byte to a counter file before printing `stdout`. The returned counter path
4736 /// lets a test assert how many `gh` subprocesses actually ran, independent of
4737 /// the #1387 request-log counter — so the two can be compared (#1389).
4738 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
4739 let guard = shim_lock();
4740 let path = dir.join("fake-gh");
4741 let counter = dir.join("gh-calls");
4742 write_exec_script(
4743 &path,
4744 &format!(
4745 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
4746 counter = counter.display()
4747 ),
4748 );
4749 (path, guard, counter)
4750 }
4751
4752 /// The number of `gh` subprocesses the counting stub recorded (0 if it never
4753 /// ran) — the length of the counter file.
4754 fn gh_spawn_count(counter: &Path) -> usize {
4755 std::fs::read(counter).map_or(0, |b| b.len())
4756 }
4757
4758 /// The number of **successful** `kind: "gh"` records the #1387 choke point
4759 /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
4760 /// on the exit code matches the ground-truth counter (which is written when the
4761 /// stub *runs*), so a rare failed spawn cannot desync the two.
4762 fn counted_gh_records(log: &Path) -> usize {
4763 std::fs::read_to_string(log)
4764 .unwrap_or_default()
4765 .lines()
4766 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
4767 .count()
4768 }
4769
4770 /// A repo with a GitHub origin and one commit on `main`.
4771 fn github_repo(dir: &Path) -> Repository {
4772 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
4773 }
4774
4775 /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
4776 /// test can register **distinct** targets (owner/name) across windows.
4777 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
4778 let repo = init_repo(dir);
4779 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4780 repo.set_head("refs/heads/main").unwrap();
4781 repo.remote("origin", url).unwrap();
4782 repo
4783 }
4784
4785 /// A pending badge whose verdict is about `head_oid`. The commit is explicit
4786 /// because the fold downgrades a badge naming a different commit than the
4787 /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
4788 /// wrong reason.
4789 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
4790 PrBadge {
4791 number,
4792 is_draft: false,
4793 checks: PrCheckState::Pending,
4794 url: "u".into(),
4795 head_oid: head_oid.to_string(),
4796 }
4797 }
4798
4799 /// Wraps a badge as the cache's resolution value (#1370).
4800 fn pr(badge: PrBadge) -> PrResolution {
4801 PrResolution::Pr(badge)
4802 }
4803
4804 #[test]
4805 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
4806 let snapshot = json!({"repos":[
4807 {
4808 "main_repo":"omni-dev",
4809 "github":{"owner":"rust-works","name":"omni-dev"},
4810 "root":"/r",
4811 // Enabled (#1376) — a not-polled repo contributes no targets (see
4812 // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
4813 "polling_enabled":true,
4814 // Two worktrees on the same branch must ask once, not twice.
4815 "worktrees":[
4816 {"path":"/r","branch":"main","is_main":true,"open":true},
4817 {"path":"/w1","branch":"main","is_main":false,"open":true},
4818 {"path":"/w2","branch":"feature","is_main":false,"open":true},
4819 // Detached: no branch, so nothing to resolve.
4820 {"path":"/w3","is_main":false,"open":true}
4821 ]
4822 },
4823 {
4824 // Not on GitHub: contributes no targets at all.
4825 "main_repo":"local","root":"/l",
4826 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
4827 }
4828 ]});
4829 let targets = pr_targets_from_snapshot(&snapshot);
4830 assert_eq!(
4831 targets,
4832 vec![
4833 PrTarget {
4834 owner: "rust-works".into(),
4835 name: "omni-dev".into(),
4836 branch: "feature".into()
4837 },
4838 PrTarget {
4839 owner: "rust-works".into(),
4840 name: "omni-dev".into(),
4841 branch: "main".into()
4842 },
4843 ]
4844 );
4845 }
4846
4847 #[test]
4848 fn pr_targets_from_snapshot_is_empty_without_repos() {
4849 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
4850 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
4851 }
4852
4853 #[test]
4854 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
4855 // Defensive: a `github` object without usable owner/name strings yields no
4856 // target rather than a half-built query.
4857 for github in [
4858 json!({}),
4859 json!({"owner": "o"}),
4860 json!({"owner": 1, "name": 2}),
4861 ] {
4862 let snapshot = json!({"repos":[{
4863 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
4864 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
4865 }]});
4866 assert!(
4867 pr_targets_from_snapshot(&snapshot).is_empty(),
4868 "{snapshot:?}"
4869 );
4870 }
4871 }
4872
4873 #[test]
4874 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
4875 // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
4876 // absent (the default-off case) or explicitly false contributes no watch,
4877 // so the poll never mentions it. Only an enabled repo is polled.
4878 for repo in [
4879 json!({
4880 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
4881 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
4882 }),
4883 json!({
4884 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
4885 "polling_enabled":false,
4886 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
4887 }),
4888 ] {
4889 let snapshot = json!({ "repos": [repo] });
4890 assert!(
4891 pr_targets_from_snapshot(&snapshot).is_empty(),
4892 "not-polled repo must yield no targets: {snapshot:?}"
4893 );
4894 }
4895 // Flipping the same repo to enabled makes it contribute.
4896 let enabled = json!({"repos":[{
4897 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
4898 "polling_enabled":true,
4899 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
4900 }]});
4901 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
4902 }
4903
4904 #[test]
4905 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
4906 let backoff = Duration::from_secs(600);
4907 // Never fetched: go.
4908 assert!(pr_should_fetch(false, None, backoff));
4909 // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
4910 // spend nothing.
4911 assert!(!pr_should_fetch(
4912 false,
4913 Some(Duration::from_secs(1)),
4914 backoff
4915 ));
4916 // Quiet tree, backoff elapsed: time to look again.
4917 assert!(pr_should_fetch(false, Some(backoff), backoff));
4918 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
4919 // The load-bearing case: the watch grew (a target added, or an upstream
4920 // pushed), so fetch **now** regardless of how deep the backoff had grown.
4921 // Without this a push waits out the full ceiling on a stale badge.
4922 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
4923 assert!(pr_should_fetch(
4924 true,
4925 Some(Duration::from_millis(1)),
4926 backoff
4927 ));
4928 }
4929
4930 #[test]
4931 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
4932 let base = Duration::from_secs(10);
4933 let fresh = Some(Duration::ZERO);
4934 let stale = Some(PENDING_FAST_WINDOW);
4935 // Pending and fresh (within the fast window): hold `base`, however long we
4936 // had backed off for.
4937 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
4938 assert_eq!(
4939 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
4940 base
4941 );
4942 // Pending but past the fast window (a long CI run): escalate — double up to
4943 // the pending ceiling, never to the terminal one.
4944 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
4945 assert_eq!(
4946 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
4947 PENDING_MAX_INTERVAL
4948 );
4949 // Pending with nothing having moved yet (`None`) is treated as past the fast
4950 // window, so a stale-from-boot pending state does not pin `base`.
4951 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
4952 // Everything terminal: double…
4953 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
4954 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
4955 // …up to the terminal ceiling (above the pending one), and never overflow.
4956 assert_eq!(
4957 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
4958 MAX_PR_POLL_INTERVAL
4959 );
4960 assert_eq!(
4961 next_pr_poll_delay(Duration::MAX, base, false, None),
4962 MAX_PR_POLL_INTERVAL
4963 );
4964 }
4965
4966 /// A watch on one branch with the given upstream tip.
4967 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
4968 PrWatch {
4969 target: PrTarget {
4970 owner: "rust-works".into(),
4971 name: "omni-dev".into(),
4972 branch: branch.into(),
4973 },
4974 upstream_sha: upstream.map(str::to_string),
4975 }
4976 }
4977
4978 #[test]
4979 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
4980 let a = watch("a", Some("111"));
4981 let b = watch("b", Some("222"));
4982 let ab = [a.clone(), b.clone()];
4983 let just_a = std::slice::from_ref(&a);
4984 let just_b = std::slice::from_ref(&b);
4985 // Nothing new: quiet tick.
4986 assert!(!pr_watch_grew(&ab, &ab));
4987 // Addition: a new target appeared.
4988 assert!(pr_watch_grew(just_a, &ab));
4989 // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
4990 assert!(!pr_watch_grew(&ab, just_a));
4991 // First sight (empty prev, from `None`): everything is new.
4992 assert!(pr_watch_grew(&[], just_a));
4993 // A push moves only the upstream — still "grew".
4994 let a_pushed = [watch("a", Some("999"))];
4995 assert!(pr_watch_grew(just_a, &a_pushed));
4996 // Gaining a target while losing another still fetches (the gain wins).
4997 assert!(pr_watch_grew(just_a, just_b));
4998 }
4999
5000 #[test]
5001 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
5002 let base = Duration::from_secs(10);
5003 let over = RateLimitSnapshot {
5004 graphql: Some(rl_resource(90)),
5005 core: Some(rl_resource(3)),
5006 search: None,
5007 };
5008 let under = RateLimitSnapshot {
5009 graphql: Some(rl_resource(50)),
5010 core: Some(rl_resource(3)),
5011 search: None,
5012 };
5013 // No reading yet: unchanged.
5014 assert_eq!(budget_throttled_delay(base, None), base);
5015 // Under the warn threshold: unchanged.
5016 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
5017 // Over: raised to at least the throttle floor…
5018 assert_eq!(
5019 budget_throttled_delay(base, Some(&over)),
5020 BUDGET_THROTTLE_INTERVAL
5021 );
5022 // …but a delay already above the floor is left alone (never shortened).
5023 let long = BUDGET_THROTTLE_INTERVAL * 2;
5024 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
5025 }
5026
5027 #[test]
5028 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
5029 // The persisted cache must survive a JSON round trip with `head_oid` intact
5030 // — it is the staleness key the tree wire drops, and losing it would render
5031 // every restored badge stale (#1389, fix 4).
5032 let target = PrTarget {
5033 owner: "rust-works".into(),
5034 name: "omni-dev".into(),
5035 branch: "main".into(),
5036 };
5037 let badge = PrResolution::Pr(PrBadge {
5038 number: 1337,
5039 is_draft: true,
5040 checks: PrCheckState::Pending,
5041 url: "http://x/1337".into(),
5042 head_oid: "deadbeef".into(),
5043 });
5044 let watched = vec![watch("main", Some("abc"))];
5045 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
5046 .unwrap()
5047 .with_timezone(&Utc);
5048 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
5049
5050 let json = serde_json::to_vec(&prefs).unwrap();
5051 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
5052 assert_eq!(back, prefs);
5053 assert_eq!(back.polled_at, Some(polled_at));
5054 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
5055 // The restored resolution equals the original — head_oid and all.
5056 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
5057 }
5058
5059 #[test]
5060 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
5061 // The explicit negative must survive persistence too: restoring `NoPr`
5062 // as "absent" would lose the #1370 distinction across a restart and
5063 // re-ask GitHub for branches already known to have no PR.
5064 let target = PrTarget {
5065 owner: "rust-works".into(),
5066 name: "omni-dev".into(),
5067 branch: "feature".into(),
5068 };
5069 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
5070 let json = serde_json::to_vec(&prefs).unwrap();
5071 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
5072 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
5073 assert_eq!(
5074 back.entries[0].resolution.clone().into_resolution(),
5075 PrResolution::NoPr
5076 );
5077 }
5078
5079 #[test]
5080 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
5081 // A file with verdicts but no `polled_at` (an older shape, or a
5082 // hand-edited one) still renders badges, but must not arm the warm
5083 // start: without a poll time there is nothing to age the verdicts
5084 // against, so the poller re-polls immediately (#1389, fix 4).
5085 let dir = tempfile::tempdir().unwrap();
5086 let path = dir.path().join("pr-cache.json");
5087 let target = PrTarget {
5088 owner: "rust-works".into(),
5089 name: "omni-dev".into(),
5090 branch: "main".into(),
5091 };
5092 let mut prefs = pr_cache_prefs_from(
5093 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
5094 &[watch("main", None)],
5095 Utc::now(),
5096 );
5097 prefs.polled_at = None;
5098 write_pr_cache(&path, &prefs).unwrap();
5099
5100 let svc = WorktreesService::new();
5101 svc.load_pr_cache(path);
5102 assert!(
5103 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
5104 "the badge itself must still restore"
5105 );
5106 assert!(
5107 svc.pr_warm_start
5108 .lock()
5109 .unwrap_or_else(PoisonError::into_inner)
5110 .is_none(),
5111 "no poll time means a cold start, not a trusted warm one"
5112 );
5113 }
5114
5115 /// Installs a thread-local WARN-level subscriber for the duration of a
5116 /// test, so degraded-path `tracing::warn!` sites actually format their
5117 /// fields instead of short-circuiting on "nobody is listening".
5118 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
5119 tracing::subscriber::set_default(
5120 tracing_subscriber::fmt()
5121 .with_max_level(tracing::Level::WARN)
5122 .with_writer(std::io::sink)
5123 .finish(),
5124 )
5125 }
5126
5127 #[test]
5128 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
5129 // The best-effort contract: a mangled cache is logged and treated as
5130 // empty — never a panic — and the path is stored regardless, so the
5131 // next successful poll rewrites a clean file (#1389, fix 4).
5132 let _trace = warn_subscriber();
5133 let dir = tempfile::tempdir().unwrap();
5134 let corrupt = dir.path().join("pr-cache.json");
5135 std::fs::write(&corrupt, b"not json").unwrap();
5136 let svc = WorktreesService::new();
5137 svc.load_pr_cache(corrupt.clone());
5138 assert!(svc.pr_cache.entries().is_empty());
5139 assert_eq!(
5140 svc.pr_cache_path
5141 .lock()
5142 .unwrap_or_else(PoisonError::into_inner)
5143 .as_deref(),
5144 Some(corrupt.as_path()),
5145 "the path must be stored even when the load fails, so persistence recovers"
5146 );
5147
5148 // A directory: `read` fails with a non-NotFound error (the distinct
5149 // "could not read" arm), with the same treated-as-empty outcome.
5150 let svc = WorktreesService::new();
5151 svc.load_pr_cache(dir.path().to_path_buf());
5152 assert!(svc.pr_cache.entries().is_empty());
5153 }
5154
5155 #[test]
5156 fn persist_pr_cache_swallows_a_write_failure() {
5157 // Best-effort: an unwritable path is logged at WARN and swallowed — the
5158 // in-memory cache stays authoritative, and losing the warm start only
5159 // costs one extra poll after the next restart.
5160 let _trace = warn_subscriber();
5161 let dir = tempfile::tempdir().unwrap();
5162 let blocker = dir.path().join("blocker");
5163 std::fs::write(&blocker, b"").unwrap();
5164 // The parent is a regular file, so creating the runtime dir must fail.
5165 let path = blocker.join("pr-cache.json");
5166 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
5167 assert!(!path.exists());
5168 // The root has no parent at all — nothing to create, and the write
5169 // itself fails (it is a directory); still swallowed.
5170 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
5171 }
5172
5173 #[tokio::test]
5174 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
5175 let dir = tempfile::tempdir().unwrap();
5176 let repo = github_repo(dir.path());
5177 let head = repo.head().unwrap().target().unwrap().to_string();
5178 let svc = WorktreesService::new();
5179 svc.handle(
5180 "register",
5181 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5182 )
5183 .await
5184 .unwrap();
5185 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5186 // act on it, as if the user had toggled it on.
5187 svc.registry.set_polling("rust-works", "omni-dev", true);
5188
5189 // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
5190 // and so is the negative, so "not resolved" stays distinguishable (#1370).
5191 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5192 assert!(wt.get("pr").is_none(), "{wt:?}");
5193 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5194
5195 // Seed the cache the poller writes, then re-read the tree.
5196 let mut badges = HashMap::new();
5197 badges.insert(
5198 PrTarget {
5199 owner: "rust-works".into(),
5200 name: "omni-dev".into(),
5201 branch: "main".into(),
5202 },
5203 pr(pending_badge(1337, &head)),
5204 );
5205 assert!(svc.pr_cache.replace(badges));
5206
5207 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5208 assert_eq!(wt["pr"]["number"], json!(1337));
5209 assert_eq!(wt["pr"]["checks"], json!("pending"));
5210 // camelCase on the wire, or the extension silently loses the draft marker.
5211 assert_eq!(wt["pr"]["isDraft"], json!(false));
5212 // A badge and the negative are mutually exclusive.
5213 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5214 }
5215
5216 #[tokio::test]
5217 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
5218 // A detached HEAD has a commit but no branch, and a badge is keyed by
5219 // branch — so there is nothing to match. It must fall through silently
5220 // rather than borrow a badge from whatever branch happens to be cached, and
5221 // rather than sink the tree.
5222 let dir = tempfile::tempdir().unwrap();
5223 let repo = github_repo(dir.path());
5224 let head = repo.head().unwrap().target().unwrap();
5225 repo.set_head_detached(head).unwrap();
5226
5227 let svc = WorktreesService::new();
5228 svc.handle(
5229 "register",
5230 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5231 )
5232 .await
5233 .unwrap();
5234 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5235 // act on it, as if the user had toggled it on.
5236 svc.registry.set_polling("rust-works", "omni-dev", true);
5237 // A badge *is* cached for `main` — the branch this worktree was on before
5238 // detaching. It must not leak onto the now-branchless row.
5239 let mut badges = HashMap::new();
5240 badges.insert(
5241 PrTarget {
5242 owner: "rust-works".into(),
5243 name: "omni-dev".into(),
5244 branch: "main".into(),
5245 },
5246 pr(pending_badge(1, &head.to_string())),
5247 );
5248 svc.pr_cache.replace(badges);
5249
5250 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5251 assert!(wt.get("branch").is_none(), "{wt:?}");
5252 // The SHA still shows — detached means no branch, not no commit.
5253 assert_eq!(
5254 wt.get("head_sha").and_then(Value::as_str),
5255 Some(head.to_string().as_str())
5256 );
5257 assert!(wt.get("pr").is_none(), "{wt:?}");
5258 // No branch means nothing was checked either: no negative on the row.
5259 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5260 }
5261
5262 #[tokio::test]
5263 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
5264 let dir = tempfile::tempdir().unwrap();
5265 github_repo(dir.path());
5266 let svc = WorktreesService::new();
5267 svc.handle(
5268 "register",
5269 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5270 )
5271 .await
5272 .unwrap();
5273 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5274 // act on it, as if the user had toggled it on.
5275 svc.registry.set_polling("rust-works", "omni-dev", true);
5276 // A badge for a different branch must not leak onto `main`.
5277 let mut badges = HashMap::new();
5278 badges.insert(
5279 PrTarget {
5280 owner: "rust-works".into(),
5281 name: "omni-dev".into(),
5282 branch: "other".into(),
5283 },
5284 pr(pending_badge(1, "irrelevant")),
5285 );
5286 svc.pr_cache.replace(badges);
5287 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5288 assert!(wt.get("pr").is_none(), "{wt:?}");
5289 // An unmatched branch is unresolved, not negative.
5290 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5291 }
5292
5293 #[tokio::test]
5294 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
5295 // The #1370 fix on the wire: a branch the poller checked and found PR-less
5296 // carries `pr_none: true` — never a sentinel `pr` object — so a client can
5297 // tell "checked, none" from "not resolved" and keep its fallback quiet.
5298 let dir = tempfile::tempdir().unwrap();
5299 github_repo(dir.path());
5300 let svc = WorktreesService::new();
5301 svc.handle(
5302 "register",
5303 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5304 )
5305 .await
5306 .unwrap();
5307 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5308 // act on it, as if the user had toggled it on.
5309 svc.registry.set_polling("rust-works", "omni-dev", true);
5310
5311 let mut resolutions = HashMap::new();
5312 resolutions.insert(
5313 PrTarget {
5314 owner: "rust-works".into(),
5315 name: "omni-dev".into(),
5316 branch: "main".into(),
5317 },
5318 PrResolution::NoPr,
5319 );
5320 assert!(svc.pr_cache.replace(resolutions));
5321
5322 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5323 assert_eq!(wt["pr_none"], json!(true));
5324 // Mutually exclusive with a badge.
5325 assert!(wt.get("pr").is_none(), "{wt:?}");
5326 }
5327
5328 #[tokio::test]
5329 async fn a_commit_does_not_drop_a_negative_resolution() {
5330 // A negative has no commit to be stale against. Dropping it when HEAD
5331 // moves would flip the row back to "unresolved" on every local commit —
5332 // re-arming every client's `gh` fallback, the very cost #1370 removes. The
5333 // poller's `moved` trigger re-checks the branch promptly instead.
5334 let dir = tempfile::tempdir().unwrap();
5335 let repo = github_repo(dir.path());
5336 let first = repo.head().unwrap().target().unwrap();
5337
5338 let svc = WorktreesService::new();
5339 svc.handle(
5340 "register",
5341 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5342 )
5343 .await
5344 .unwrap();
5345 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5346 // act on it, as if the user had toggled it on.
5347 svc.registry.set_polling("rust-works", "omni-dev", true);
5348
5349 let mut resolutions = HashMap::new();
5350 resolutions.insert(
5351 PrTarget {
5352 owner: "rust-works".into(),
5353 name: "omni-dev".into(),
5354 branch: "main".into(),
5355 },
5356 PrResolution::NoPr,
5357 );
5358 svc.pr_cache.replace(resolutions);
5359
5360 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5361 assert_eq!(wt["pr_none"], json!(true));
5362
5363 // Commit — as a push would leave things, with the cache untouched.
5364 let head = repo.find_commit(first).unwrap();
5365 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
5366
5367 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5368 assert_eq!(
5369 wt["pr_none"],
5370 json!(true),
5371 "a local commit must not drop the negative"
5372 );
5373 }
5374
5375 #[tokio::test]
5376 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
5377 // The idle case — the daemon runs all day with no editor open. Point it at a
5378 // stub that fails loudly if ever spawned: a poll here would both waste
5379 // GitHub budget and, on a real `gh`, wake the radio for nothing.
5380 let bin_dir = tempfile::tempdir().unwrap();
5381 let marker = bin_dir.path().join("spawned");
5382 let fake = bin_dir.path().join("fake-gh");
5383 std::fs::write(
5384 &fake,
5385 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
5386 )
5387 .unwrap();
5388 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5389 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5390 std::fs::set_permissions(&fake, perms).unwrap();
5391
5392 let svc = WorktreesService::new();
5393 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5394 tokio::time::sleep(Duration::from_millis(200)).await;
5395 svc.shutdown().await;
5396 assert!(
5397 !marker.exists(),
5398 "the poller must not spawn gh with no windows registered"
5399 );
5400 }
5401
5402 #[tokio::test]
5403 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
5404 // Badges are decoration: an unauthenticated or broken `gh` must never sink
5405 // the tree, and one bad poll must not blank rows that were fine a second ago.
5406 let dir = tempfile::tempdir().unwrap();
5407 let repo = github_repo(dir.path());
5408 let head = repo.head().unwrap().target().unwrap().to_string();
5409 let bin_dir = tempfile::tempdir().unwrap();
5410 let fake = bin_dir.path().join("fake-gh");
5411 // Exits non-zero, exactly as `gh` does without `gh auth login`.
5412 std::fs::write(
5413 &fake,
5414 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
5415 )
5416 .unwrap();
5417 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5418 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5419 std::fs::set_permissions(&fake, perms).unwrap();
5420
5421 let svc = WorktreesService::new();
5422 svc.handle(
5423 "register",
5424 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5425 )
5426 .await
5427 .unwrap();
5428 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5429 // act on it, as if the user had toggled it on.
5430 svc.registry.set_polling("rust-works", "omni-dev", true);
5431 // Seed a badge as though an earlier poll had succeeded.
5432 let mut seeded = HashMap::new();
5433 seeded.insert(
5434 PrTarget {
5435 owner: "rust-works".into(),
5436 name: "omni-dev".into(),
5437 branch: "main".into(),
5438 },
5439 pr(pending_badge(7, &head)),
5440 );
5441 svc.pr_cache.replace(seeded);
5442
5443 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5444 tokio::time::sleep(Duration::from_millis(200)).await;
5445
5446 // The tree still serves, and the seeded badge survived the failing polls —
5447 // which also minted no false "no PR" negatives (#1370).
5448 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5449 assert_eq!(wt["pr"]["number"], json!(7));
5450 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5451 svc.shutdown().await;
5452 }
5453
5454 #[tokio::test]
5455 // The shim guard is deliberately held across the awaits below: it must span
5456 // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
5457 // is against another test writing while this one forks. Safe here — only test
5458 // threads take it, never a task inside the runtime, so it cannot deadlock.
5459 // Scoped per-test rather than on the module, which would also silence the
5460 // registry lock's "never held across .await" invariant.
5461 #[allow(clippy::await_holding_lock)]
5462 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
5463 // The normal startup order: the daemon starts at login, *before* any
5464 // editor. It therefore sees an empty tree and backs off to the 30-minute
5465 // ceiling — so unless a register wakes it, the first badge of the session
5466 // arrives up to half an hour after the window does, which reads as the
5467 // feature being broken rather than slow.
5468 let dir = tempfile::tempdir().unwrap();
5469 github_repo(dir.path());
5470 let bin_dir = tempfile::tempdir().unwrap();
5471 let (fake, _shim) = fake_gh(
5472 bin_dir.path(),
5473 r#"{"data":{"r0":{"b0":{
5474 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
5475 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
5476 ]}}},
5477 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
5478 }}}}"#,
5479 );
5480
5481 let svc = WorktreesService::new();
5482 // Poller first, with nothing registered — it backs off on the empty tree.
5483 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
5484 tokio::time::sleep(Duration::from_millis(150)).await;
5485
5486 // Now an editor opens.
5487 svc.handle(
5488 "register",
5489 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5490 )
5491 .await
5492 .unwrap();
5493 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5494 // act on it, as if the user had toggled it on.
5495 svc.registry.set_polling("rust-works", "omni-dev", true);
5496
5497 // The badge must follow promptly — the register wakes the loop out of its
5498 // backoff. The deadline is orders of magnitude below the ceiling, so this
5499 // fails on the bug rather than merely being slow.
5500 let badge = tokio::time::timeout(Duration::from_secs(30), async {
5501 loop {
5502 if let Some(PrResolution::Pr(badge)) =
5503 svc.pr_cache.get("rust-works", "omni-dev", "main")
5504 {
5505 return badge;
5506 }
5507 tokio::time::sleep(Duration::from_millis(25)).await;
5508 }
5509 })
5510 .await
5511 .expect("a window opening must wake the poller out of its idle backoff");
5512 assert_eq!(badge.number, 99);
5513 svc.shutdown().await;
5514 }
5515
5516 #[tokio::test]
5517 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
5518 // The acceptance criterion: "pushing a new commit invalidates the badge
5519 // rather than leaving the previous head's verdict standing."
5520 //
5521 // The cache still holds the verdict for the *old* commit, and the poller may
5522 // have backed off for up to half an hour. So the fold — which runs on every
5523 // snapshot — has to notice on its own, with no network call.
5524 let dir = tempfile::tempdir().unwrap();
5525 let repo = github_repo(dir.path());
5526 let first = repo.head().unwrap().target().unwrap();
5527
5528 let svc = WorktreesService::new();
5529 svc.handle(
5530 "register",
5531 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5532 )
5533 .await
5534 .unwrap();
5535 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5536 // act on it, as if the user had toggled it on.
5537 svc.registry.set_polling("rust-works", "omni-dev", true);
5538
5539 // A green verdict, correctly describing the commit currently checked out.
5540 let mut badges = HashMap::new();
5541 badges.insert(
5542 PrTarget {
5543 owner: "rust-works".into(),
5544 name: "omni-dev".into(),
5545 branch: "main".into(),
5546 },
5547 pr(PrBadge {
5548 number: 1337,
5549 is_draft: false,
5550 checks: PrCheckState::Success,
5551 url: "u".into(),
5552 head_oid: first.to_string(),
5553 }),
5554 );
5555 svc.pr_cache.replace(badges);
5556
5557 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5558 assert_eq!(
5559 wt["pr"]["checks"],
5560 json!("success"),
5561 "green for its own commit"
5562 );
5563
5564 // Now commit — as a push would leave things, with the cache untouched.
5565 let head = repo.find_commit(first).unwrap();
5566 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
5567
5568 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5569 assert_eq!(
5570 wt["pr"]["checks"],
5571 json!("pending"),
5572 "the previous commit's ✓ must not stand after a new commit"
5573 );
5574 // The PR itself is still shown — it is the *verdict* that is unknown, not
5575 // the PR.
5576 assert_eq!(wt["pr"]["number"], json!(1337));
5577 }
5578
5579 #[test]
5580 fn is_stale_for_compares_the_commit_the_verdict_describes() {
5581 let badge = pending_badge(1, "aaa");
5582 assert!(!badge.is_stale_for(Some("aaa")));
5583 assert!(badge.is_stale_for(Some("bbb")));
5584 // No local HEAD (unborn): nothing to compare against, so not stale.
5585 assert!(!badge.is_stale_for(None));
5586 }
5587
5588 #[test]
5589 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
5590 // #1389, fix 3. A local commit moves only the head — GitHub has not seen
5591 // it — so asking would return exactly the cached verdict, and the badge
5592 // stays correctly stale via `is_stale_for` with no network. So a snapshot
5593 // that differs *only* in `head_sha` must compare **equal** as a watch.
5594 let snap = |sha: &str| {
5595 json!({"repos":[{
5596 "main_repo":"omni-dev",
5597 "github":{"owner":"rust-works","name":"omni-dev"},
5598 "root":"/r",
5599 "polling_enabled":true,
5600 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
5601 }]})
5602 };
5603 let before = pr_watch_from_snapshot(&snap("aaa"));
5604 let after = pr_watch_from_snapshot(&snap("bbb"));
5605 assert_eq!(before.len(), 1);
5606 assert_eq!(before[0].target, after[0].target);
5607 // The head moved, but the watch did not — no fetch trigger, no `gh` call.
5608 assert_eq!(before, after);
5609 assert!(!pr_watch_grew(&before, &after));
5610 }
5611
5612 #[test]
5613 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
5614 // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
5615 // it moves no local head — so an upstream move alone must register as "go
5616 // and ask now", or the badge sits at `●` until the backoff elapses.
5617 let snap = |upstream: &str| {
5618 json!({"repos":[{
5619 "main_repo":"omni-dev",
5620 "github":{"owner":"rust-works","name":"omni-dev"},
5621 "root":"/r",
5622 "polling_enabled":true,
5623 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
5624 "upstream_sha":upstream,"is_main":true,"open":true}]
5625 }]})
5626 };
5627 let before = pr_watch_from_snapshot(&snap("aaa"));
5628 let after = pr_watch_from_snapshot(&snap("bbb"));
5629 // Same target, only the upstream moved — a genuine "grew" signal.
5630 assert_eq!(before.len(), 1);
5631 assert_eq!(before[0].target, after[0].target);
5632 assert_ne!(before, after);
5633 assert!(pr_watch_grew(&before, &after));
5634 // A quiet tick still asks nothing.
5635 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
5636 assert!(!pr_watch_grew(
5637 &before,
5638 &pr_watch_from_snapshot(&snap("aaa"))
5639 ));
5640 }
5641
5642 #[test]
5643 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
5644 // An older daemon — or any branch tracking nothing — simply sends no
5645 // `upstream_sha`, which reads as `None` rather than failing the poll.
5646 let snap = json!({"repos":[{
5647 "main_repo":"omni-dev",
5648 "github":{"owner":"rust-works","name":"omni-dev"},
5649 "root":"/r",
5650 "polling_enabled":true,
5651 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
5652 }]});
5653 let watch = pr_watch_from_snapshot(&snap);
5654 assert_eq!(watch.len(), 1);
5655 assert_eq!(watch[0].upstream_sha, None);
5656 }
5657
5658 #[test]
5659 fn start_pr_poller_is_a_noop_outside_a_runtime() {
5660 let svc = WorktreesService::new();
5661 svc.start_pr_poller();
5662 assert!(svc
5663 .poller
5664 .lock()
5665 .unwrap_or_else(PoisonError::into_inner)
5666 .is_none());
5667 }
5668
5669 #[tokio::test]
5670 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
5671 let svc = WorktreesService::new();
5672 svc.start_pr_poller_with(
5673 Duration::from_millis(50),
5674 Duration::from_millis(10),
5675 PathBuf::from("/bin/true"),
5676 );
5677 let token = svc
5678 .poller
5679 .lock()
5680 .unwrap_or_else(PoisonError::into_inner)
5681 .as_ref()
5682 .map(|t| t.token.clone())
5683 .expect("poller started");
5684
5685 // Cancel the live task, then start again: if `start` spawned a replacement
5686 // it would orphan this one, so the token staying cancelled proves it did not.
5687 token.cancel();
5688 svc.start_pr_poller_with(
5689 Duration::from_millis(50),
5690 Duration::from_millis(10),
5691 PathBuf::from("/bin/true"),
5692 );
5693 assert!(svc
5694 .poller
5695 .lock()
5696 .unwrap_or_else(PoisonError::into_inner)
5697 .as_ref()
5698 .is_some_and(|t| t.token.is_cancelled()));
5699
5700 svc.shutdown().await;
5701 assert!(svc
5702 .poller
5703 .lock()
5704 .unwrap_or_else(PoisonError::into_inner)
5705 .is_none());
5706 }
5707
5708 // --- Rate-limit monitor (#1375) ---
5709
5710 /// A resource at `used`% of a 100-request budget.
5711 fn rl_resource(used: u64) -> RateLimitResource {
5712 RateLimitResource {
5713 used,
5714 limit: 100,
5715 remaining: 100 - used,
5716 percent: used as f64,
5717 reset: 0,
5718 }
5719 }
5720
5721 #[test]
5722 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
5723 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
5724 graphql: Some(rl_resource(graphql)),
5725 core: Some(rl_resource(core)),
5726 search: None,
5727 };
5728 // First poll already over threshold → warn.
5729 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
5730 // First poll below → no warn.
5731 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
5732 // Crossing upward → warn.
5733 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
5734 // Staying over → no repeat warn.
5735 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
5736 // Recovering below → no warn.
5737 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
5738 // A *different* resource crossing while the first recovers is still caught.
5739 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
5740 }
5741
5742 #[test]
5743 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
5744 let svc = WorktreesService::new();
5745 svc.start_rate_limit_poller();
5746 assert!(svc
5747 .rate_limit_poller
5748 .lock()
5749 .unwrap_or_else(PoisonError::into_inner)
5750 .is_none());
5751 }
5752
5753 #[tokio::test]
5754 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
5755 let svc = WorktreesService::new();
5756 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
5757 let token = svc
5758 .rate_limit_poller
5759 .lock()
5760 .unwrap_or_else(PoisonError::into_inner)
5761 .as_ref()
5762 .map(|t| t.token.clone())
5763 .expect("poller started");
5764
5765 // Cancel the live task, then start again: a second start must not spawn a
5766 // replacement (which would orphan this one), so the token stays cancelled.
5767 token.cancel();
5768 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
5769 assert!(svc
5770 .rate_limit_poller
5771 .lock()
5772 .unwrap_or_else(PoisonError::into_inner)
5773 .as_ref()
5774 .is_some_and(|t| t.token.is_cancelled()));
5775
5776 svc.shutdown().await;
5777 assert!(svc
5778 .rate_limit_poller
5779 .lock()
5780 .unwrap_or_else(PoisonError::into_inner)
5781 .is_none());
5782 }
5783
5784 #[tokio::test]
5785 // Holds the shim guard across awaits; see the note above.
5786 #[allow(clippy::await_holding_lock)]
5787 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
5788 let bin_dir = tempfile::tempdir().unwrap();
5789 let (fake, _shim) = fake_gh(
5790 bin_dir.path(),
5791 r#"{"resources":{
5792 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
5793 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
5794 }}"#,
5795 );
5796 let svc = WorktreesService::new();
5797 // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
5798 // is being watched — a lease makes it active without needing a window/folder.
5799 svc.registry.set_polling("rust-works", "omni-dev", true);
5800 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
5801
5802 // Each poll spawns a real subprocess; wait on a generous deadline so a
5803 // loaded machine fails honestly rather than flaking.
5804 let snap = tokio::time::timeout(Duration::from_secs(30), async {
5805 loop {
5806 if let Some(snap) = svc.rate_limit_cache.get() {
5807 return snap;
5808 }
5809 tokio::time::sleep(Duration::from_millis(25)).await;
5810 }
5811 })
5812 .await
5813 .expect("poller should populate the cache through the fake gh");
5814 assert_eq!(snap.graphql.unwrap().used, 4100);
5815 assert_eq!(snap.core.unwrap().used, 27);
5816
5817 // The reading reaches the built-in status field via the shared cache.
5818 assert!(svc.rate_limit_cache().get().is_some());
5819
5820 svc.shutdown().await;
5821 assert!(svc
5822 .rate_limit_poller
5823 .lock()
5824 .unwrap_or_else(PoisonError::into_inner)
5825 .is_none());
5826 }
5827
5828 #[tokio::test]
5829 // Holds the shim guard across awaits; see the note above.
5830 #[allow(clippy::await_holding_lock)]
5831 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
5832 // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
5833 // `/rate_limit` subprocess — the counting stub records zero spawns.
5834 let bin_dir = tempfile::tempdir().unwrap();
5835 let (fake, _shim, counter) = counting_fake_gh(
5836 bin_dir.path(),
5837 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
5838 );
5839 let svc = WorktreesService::new();
5840 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
5841
5842 // Give the loop several ticks; with nothing registered it must never poll.
5843 tokio::time::sleep(Duration::from_millis(300)).await;
5844 assert_eq!(
5845 gh_spawn_count(&counter),
5846 0,
5847 "idle daemon must not poll (#1389, fix 8b)"
5848 );
5849 assert!(svc.rate_limit_cache.get().is_none());
5850
5851 // Once a lease is active, the next tick populates the cache.
5852 svc.registry.set_polling("rust-works", "omni-dev", true);
5853 tokio::time::timeout(Duration::from_secs(30), async {
5854 loop {
5855 if svc.rate_limit_cache.get().is_some() {
5856 return;
5857 }
5858 tokio::time::sleep(Duration::from_millis(25)).await;
5859 }
5860 })
5861 .await
5862 .expect("an active lease should resume polling");
5863 assert!(gh_spawn_count(&counter) >= 1);
5864 svc.shutdown().await;
5865 }
5866
5867 #[tokio::test]
5868 async fn rate_limit_poller_survives_a_failing_gh() {
5869 // A missing/failing `gh` leaves the cache empty and never wedges the loop —
5870 // the degraded branch keeps the last (here, absent) reading rather than
5871 // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
5872 let svc = WorktreesService::new();
5873 svc.registry.set_polling("rust-works", "omni-dev", true);
5874 svc.start_rate_limit_poller_with(
5875 Duration::from_millis(20),
5876 PathBuf::from("/no/such/gh/xyzzy"),
5877 );
5878 // Let it fail a few times: the cache stays empty and the task stays alive.
5879 tokio::time::sleep(Duration::from_millis(150)).await;
5880 assert!(svc.rate_limit_cache.get().is_none());
5881 assert!(
5882 svc.rate_limit_poller
5883 .lock()
5884 .unwrap_or_else(PoisonError::into_inner)
5885 .is_some(),
5886 "the loop must survive a failing gh, not panic out"
5887 );
5888 svc.shutdown().await;
5889 }
5890
5891 #[test]
5892 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
5893 let svc = WorktreesService::new();
5894 // Empty cache → no rate-limit line (the pre-#1375 shape).
5895 let items = svc.menu().items;
5896 assert!(
5897 !items
5898 .iter()
5899 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
5900 "no github line before the first poll"
5901 );
5902
5903 // Populate the cache → the first item is the rate-limit status line.
5904 svc.rate_limit_cache.replace(RateLimitSnapshot {
5905 graphql: Some(rl_resource(82)),
5906 core: Some(rl_resource(3)),
5907 search: None,
5908 });
5909 let items = svc.menu().items;
5910 assert!(
5911 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
5912 "expected the github line first, got {items:?}"
5913 );
5914 assert!(
5915 matches!(items.get(1), Some(MenuItem::Separator)),
5916 "expected a separator after the github line"
5917 );
5918 }
5919
5920 #[tokio::test]
5921 // Holds the shim guard across awaits; see the note above.
5922 #[allow(clippy::await_holding_lock)]
5923 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
5924 let dir = tempfile::tempdir().unwrap();
5925 github_repo(dir.path());
5926 let bin_dir = tempfile::tempdir().unwrap();
5927 // One repo, one branch → aliases r0/b0. A still-running check so the badge
5928 // stays pending and the loop keeps its fast cadence.
5929 let (fake, _shim) = fake_gh(
5930 bin_dir.path(),
5931 r#"{"data":{"r0":{"b0":{
5932 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
5933 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
5934 ]}}},
5935 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
5936 }}}}"#,
5937 );
5938 let svc = WorktreesService::new();
5939 svc.handle(
5940 "register",
5941 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5942 )
5943 .await
5944 .unwrap();
5945 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5946 // act on it, as if the user had toggled it on.
5947 svc.registry.set_polling("rust-works", "omni-dev", true);
5948 svc.start_pr_poller_with(
5949 Duration::from_millis(50),
5950 Duration::from_millis(10),
5951 fake.clone(),
5952 );
5953
5954 // Wait on a generous wall-clock deadline: each poll spawns a real
5955 // subprocess, and under a loaded machine (a full `build.sh` runs a build
5956 // and clippy alongside) a tight budget flakes rather than fails honestly.
5957 let badge = tokio::time::timeout(Duration::from_secs(30), async {
5958 loop {
5959 if let Some(PrResolution::Pr(badge)) =
5960 svc.pr_cache.get("rust-works", "omni-dev", "main")
5961 {
5962 return badge;
5963 }
5964 tokio::time::sleep(Duration::from_millis(25)).await;
5965 }
5966 })
5967 .await
5968 .expect("poller should resolve a badge through the fake gh");
5969 assert_eq!(badge.number, 1337);
5970 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
5971
5972 // The badge reaches the wire the windows actually read.
5973 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5974 assert_eq!(wt["pr"]["number"], json!(1337));
5975
5976 // And the loop is quiescent after shutdown: the generation must stop moving.
5977 svc.shutdown().await;
5978 let generation = svc.registry.change_generation();
5979 tokio::time::sleep(Duration::from_millis(120)).await;
5980 assert_eq!(
5981 svc.registry.change_generation(),
5982 generation,
5983 "no bumps after shutdown"
5984 );
5985 }
5986
5987 #[tokio::test]
5988 // Holds the shim guard across awaits; see the note above.
5989 #[allow(clippy::await_holding_lock)]
5990 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
5991 // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
5992 // the poller folds into the shared cache — so the graphql figure stays fresh
5993 // without a standalone `/rate_limit` call.
5994 let dir = tempfile::tempdir().unwrap();
5995 github_repo(dir.path());
5996 let bin_dir = tempfile::tempdir().unwrap();
5997 let (fake, _shim) = fake_gh(
5998 bin_dir.path(),
5999 r#"{"data":{
6000 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
6001 "resetAt":"2026-07-21T16:00:00Z"},
6002 "r0":{"b0":{
6003 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6004 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6005 ]}}},
6006 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
6007 }}
6008 }}"#,
6009 );
6010 let svc = WorktreesService::new();
6011 svc.handle(
6012 "register",
6013 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6014 )
6015 .await
6016 .unwrap();
6017 svc.registry.set_polling("rust-works", "omni-dev", true);
6018 // No rate-limit poller started: the only writer of the cache is the PR poll's
6019 // folded-in budget, so a populated graphql reading proves fix 8a.
6020 svc.start_pr_poller_with(
6021 Duration::from_millis(50),
6022 Duration::from_millis(10),
6023 fake.clone(),
6024 );
6025
6026 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
6027 loop {
6028 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
6029 return g;
6030 }
6031 tokio::time::sleep(Duration::from_millis(25)).await;
6032 }
6033 })
6034 .await
6035 .expect("the PR poll should fold its budget into the cache");
6036 assert_eq!(graphql.used, 123);
6037 assert_eq!(graphql.limit, 5000);
6038 assert_eq!(graphql.remaining, 4877);
6039 svc.shutdown().await;
6040 }
6041
6042 #[tokio::test]
6043 // Holds the shim guard across awaits; see the note above.
6044 #[allow(clippy::await_holding_lock)]
6045 async fn pr_poll_counts_every_gh_call_exactly_once() {
6046 // #1389's non-negotiable constraint (#1387): fewer calls, but every call
6047 // still counted. Compares the ground-truth number of `gh` subprocesses the
6048 // poll actually spawned against the number of successful `kind:"gh"` records
6049 // the counted `run_gh` choke point wrote — they must be equal, so a future
6050 // refactor cannot add an uncounted `gh` path (counted < spawns) without
6051 // failing here.
6052 let dir = tempfile::tempdir().unwrap();
6053 github_repo(dir.path());
6054 let bin_dir = tempfile::tempdir().unwrap();
6055 let (fake, _shim, counter) = counting_fake_gh(
6056 bin_dir.path(),
6057 r#"{"data":{"r0":{"b0":{
6058 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6059 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6060 ]}}},
6061 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
6062 }}}}"#,
6063 );
6064 let log = bin_dir.path().join("log.jsonl");
6065 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
6066
6067 let svc = WorktreesService::new();
6068 svc.handle(
6069 "register",
6070 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6071 )
6072 .await
6073 .unwrap();
6074 svc.registry.set_polling("rust-works", "omni-dev", true);
6075 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
6076
6077 // Wait for at least one fetch to land.
6078 tokio::time::timeout(Duration::from_secs(30), async {
6079 loop {
6080 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
6081 return;
6082 }
6083 tokio::time::sleep(Duration::from_millis(25)).await;
6084 }
6085 })
6086 .await
6087 .expect("poller should fetch through the fake gh");
6088
6089 // Stop the loop so both counts are final (no in-flight gh), then compare.
6090 svc.shutdown().await;
6091 let spawns = gh_spawn_count(&counter);
6092 let counted = counted_gh_records(&log);
6093 std::env::remove_var("OMNI_DEV_LOG_FILE");
6094 assert!(
6095 spawns >= 1,
6096 "the poll should have spent at least one gh call"
6097 );
6098 assert_eq!(
6099 counted, spawns,
6100 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
6101 );
6102 }
6103
6104 #[tokio::test]
6105 // Holds the shim guard across awaits; see the note above.
6106 #[allow(clippy::await_holding_lock)]
6107 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
6108 // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
6109 // its windows) that each *grow* the watch must collapse into ONE fetch on
6110 // the final set, not one per window. Two distinct repos appear back-to-back
6111 // inside the debounce window; a debounce-free loop would fetch twice.
6112 let dir_a = tempfile::tempdir().unwrap();
6113 let dir_b = tempfile::tempdir().unwrap();
6114 github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
6115 github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
6116 let bin_dir = tempfile::tempdir().unwrap();
6117 // Both terminal, so no fast pending cadence can add a second fetch.
6118 let (fake, _shim, counter) = counting_fake_gh(
6119 bin_dir.path(),
6120 r#"{"data":{
6121 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
6122 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6123 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
6124 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
6125 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6126 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
6127 }}"#,
6128 );
6129 let svc = WorktreesService::new();
6130 // Enable polling for both before they register, so the first snapshot after
6131 // the storm already counts them.
6132 svc.registry.set_polling("rust-works", "omni-dev", true);
6133 svc.registry.set_polling("rust-works", "other-repo", true);
6134 // `base` far larger than the test so only the storm — never the cadence —
6135 // can trigger a fetch; a generous debounce so the two registers land inside
6136 // one settle window even on a loaded machine.
6137 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
6138 // The burst: both windows register back-to-back.
6139 svc.handle(
6140 "register",
6141 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
6142 )
6143 .await
6144 .unwrap();
6145 // A beat between the two, so the first bump has (all but certainly)
6146 // woken the poller into its settle window before the second arrives —
6147 // exercising the debounce *restart*, not just a single coalesced wake.
6148 tokio::time::sleep(Duration::from_millis(50)).await;
6149 svc.handle(
6150 "register",
6151 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
6152 )
6153 .await
6154 .unwrap();
6155
6156 // Wait until both badges resolve — proving the single fetch covered the full
6157 // final set, not just the first window.
6158 tokio::time::timeout(Duration::from_secs(30), async {
6159 loop {
6160 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
6161 let b = svc
6162 .pr_cache
6163 .get("rust-works", "other-repo", "main")
6164 .is_some();
6165 if a && b {
6166 return;
6167 }
6168 tokio::time::sleep(Duration::from_millis(25)).await;
6169 }
6170 })
6171 .await
6172 .expect("the debounced fetch should resolve both repos");
6173
6174 svc.shutdown().await;
6175 assert_eq!(
6176 gh_spawn_count(&counter),
6177 1,
6178 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
6179 );
6180 }
6181
6182 #[tokio::test]
6183 // Holds the shim guard across awaits; see the note above.
6184 #[allow(clippy::await_holding_lock)]
6185 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
6186 // The settle loop is bounded: a drip of registry bumps, each landing
6187 // inside the debounce window, must not postpone the poll forever — the
6188 // overall deadline (4× the debounce) forces the snapshot mid-storm
6189 // (#1389, fix 2).
6190 let dir = tempfile::tempdir().unwrap();
6191 github_repo(dir.path());
6192 let bin_dir = tempfile::tempdir().unwrap();
6193 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6194 let svc = WorktreesService::new();
6195 svc.registry.set_polling("rust-works", "omni-dev", true);
6196 // `base` far larger than the test, so only the grew-trigger — released
6197 // by the deadline — can fetch.
6198 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(50), fake);
6199 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
6200 svc.handle("register", register.clone()).await.unwrap();
6201 // Re-register (an upsert, but still a bump) every 25ms — inside the
6202 // 50ms debounce — for well past the 200ms deadline. A deadline-free
6203 // loop would still be settling when the drip ends.
6204 for _ in 0..24 {
6205 tokio::time::sleep(Duration::from_millis(25)).await;
6206 svc.handle("register", register.clone()).await.unwrap();
6207 }
6208 let spawned_mid_drip = gh_spawn_count(&counter);
6209 svc.shutdown().await;
6210 assert!(
6211 spawned_mid_drip >= 1,
6212 "the deadline must force a fetch while the drip is still running (#1389, fix 2)"
6213 );
6214 }
6215
6216 #[tokio::test]
6217 // Holds the shim guard across awaits; see the note above.
6218 #[allow(clippy::await_holding_lock)]
6219 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
6220 // #1389, fix 4: a daemon restart within the backoff window serves badges
6221 // from the persisted cache and spends **no** gh call, because every current
6222 // target already has a fresh verdict.
6223 let dir = tempfile::tempdir().unwrap();
6224 let repo = github_repo(dir.path());
6225 let head = repo.head().unwrap().target().unwrap().to_string();
6226 let bin_dir = tempfile::tempdir().unwrap();
6227 // If the poller wrongly fetched, this empty reply would still spawn the stub
6228 // and bump the counter — which is exactly what the assertion catches.
6229 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6230
6231 // Persist a fresh cache the way the previous daemon would have: a badge for
6232 // `main` whose verdict is about the current head (so it is not stale),
6233 // watched at `(main, no upstream)`, resolved just now.
6234 let cache_path = bin_dir.path().join("pr-cache.json");
6235 let target = PrTarget {
6236 owner: "rust-works".into(),
6237 name: "omni-dev".into(),
6238 branch: "main".into(),
6239 };
6240 let prefs = pr_cache_prefs_from(
6241 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
6242 &[watch("main", None)],
6243 Utc::now(),
6244 );
6245 write_pr_cache(&cache_path, &prefs).unwrap();
6246
6247 let svc = WorktreesService::new();
6248 svc.load_pr_cache(cache_path);
6249 svc.registry.set_polling("rust-works", "omni-dev", true);
6250 svc.handle(
6251 "register",
6252 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6253 )
6254 .await
6255 .unwrap();
6256 // `base` far larger than the test: the only fetch that could happen is the
6257 // immediate one we expect the warm cache to skip.
6258 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
6259
6260 // The restored badge renders on the wire without a gh call.
6261 let number = tokio::time::timeout(Duration::from_secs(30), async {
6262 loop {
6263 let tree = svc.handle("tree", Value::Null).await.unwrap();
6264 if let Some(n) = repos_of(&tree)
6265 .first()
6266 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
6267 {
6268 return n;
6269 }
6270 tokio::time::sleep(Duration::from_millis(25)).await;
6271 }
6272 })
6273 .await
6274 .expect("the restored badge should render from the warm cache");
6275 assert_eq!(number, 1337);
6276
6277 // Let the poller run a while, then confirm it stayed quiet.
6278 tokio::time::sleep(Duration::from_millis(300)).await;
6279 svc.shutdown().await;
6280 assert_eq!(
6281 gh_spawn_count(&counter),
6282 0,
6283 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
6284 );
6285 }
6286
6287 #[tokio::test]
6288 // Holds the shim guard across awaits; see the note above.
6289 #[allow(clippy::await_holding_lock)]
6290 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
6291 // #1389, fix 4, write side (the twin of the skip test above, which reads
6292 // a hand-written file): a successful resolve persists the cache —
6293 // creating the runtime dir if needed — so the *next* daemon restart
6294 // warm-starts from it.
6295 let dir = tempfile::tempdir().unwrap();
6296 github_repo(dir.path());
6297 let bin_dir = tempfile::tempdir().unwrap();
6298 let (fake, _shim) = fake_gh(
6299 bin_dir.path(),
6300 r#"{"data":{"r0":{"b0":{
6301 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6302 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6303 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
6304 }}}}"#,
6305 );
6306 let svc = WorktreesService::new();
6307 // No file yet, and no parent dir either: the load takes the benign
6308 // NotFound arm, and the write must create the `0700` runtime dir.
6309 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
6310 svc.load_pr_cache(cache_path.clone());
6311 svc.registry.set_polling("rust-works", "omni-dev", true);
6312 svc.handle(
6313 "register",
6314 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6315 )
6316 .await
6317 .unwrap();
6318 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
6319
6320 // A partially-written or not-yet-written file simply retries: only a
6321 // fully parseable cache ends the wait.
6322 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
6323 loop {
6324 if let Ok(bytes) = std::fs::read(&cache_path) {
6325 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
6326 if !prefs.entries.is_empty() {
6327 return prefs;
6328 }
6329 }
6330 }
6331 tokio::time::sleep(Duration::from_millis(25)).await;
6332 }
6333 })
6334 .await
6335 .expect("a successful resolve should persist the cache file");
6336 svc.shutdown().await;
6337
6338 assert_eq!(prefs.entries[0].target.branch, "main");
6339 assert!(
6340 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
6341 "{:?}",
6342 prefs.entries[0].resolution
6343 );
6344 assert_eq!(
6345 prefs.watched,
6346 vec![PersistedWatch {
6347 target: prefs.entries[0].target.clone(),
6348 upstream_sha: None
6349 }]
6350 );
6351 assert!(
6352 prefs.polled_at.is_some(),
6353 "the poll time is what ages the next warm start"
6354 );
6355 }
6356
6357 #[tokio::test]
6358 // Holds the shim guard across awaits; see the note above.
6359 #[allow(clippy::await_holding_lock)]
6360 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
6361 // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
6362 // to one counted `gh pr list` per repo. A generous TTL, so the second call
6363 // is served from the cache and spawns **no** second `gh` — the whole point.
6364 let bin_dir = tempfile::tempdir().unwrap();
6365 let (fake, _shim, counter) = counting_fake_gh(
6366 bin_dir.path(),
6367 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
6368 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
6369 );
6370 let svc = WorktreesService::new();
6371
6372 let prs = svc
6373 .open_prs_with("rust-works", "omni-dev", fake.clone())
6374 .await
6375 .expect("gh pr list should resolve");
6376 assert_eq!(prs.len(), 1);
6377 assert_eq!(prs[0]["number"], json!(42));
6378 assert_eq!(prs[0]["url"], json!("http://x/42"));
6379 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
6380
6381 // A second window asking the same repo is served from the shared cache.
6382 let again = svc
6383 .open_prs_with("rust-works", "omni-dev", fake.clone())
6384 .await
6385 .expect("cache hit should resolve");
6386 assert_eq!(again, prs);
6387 assert_eq!(
6388 gh_spawn_count(&counter),
6389 1,
6390 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
6391 );
6392
6393 // The op wrapper shapes the reply and validates the payload.
6394 let reply = svc
6395 .handle(
6396 "open-prs",
6397 json!({ "owner": "rust-works", "name": "omni-dev" }),
6398 )
6399 .await
6400 .expect("open-prs op should route");
6401 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
6402 assert!(svc
6403 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
6404 .await
6405 .is_err());
6406 }
6407
6408 #[test]
6409 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
6410 // The three degraded shapes a real `gh` presents — not installed, a
6411 // nonzero exit (auth/network), and output that is not the JSON array
6412 // the menu indexes into — must each be a distinct, actionable error
6413 // rather than a panic or a silently empty list (#1389, fix 7).
6414 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
6415 assert!(
6416 err.to_string().contains("is the GitHub CLI installed"),
6417 "{err:#}"
6418 );
6419
6420 let bin_dir = tempfile::tempdir().unwrap();
6421 let _guard = shim_lock();
6422 let failing = bin_dir.path().join("fake-gh-fails");
6423 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
6424 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
6425 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
6426 assert!(err.to_string().contains("boom"), "{err:#}");
6427
6428 let object = bin_dir.path().join("fake-gh-object");
6429 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
6430 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
6431 assert!(
6432 err.to_string().contains("did not return a JSON array"),
6433 "{err:#}"
6434 );
6435 }
6436
6437 #[tokio::test]
6438 // Holds the shim guard across awaits; see the note above.
6439 #[allow(clippy::await_holding_lock)]
6440 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
6441 // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
6442 // cadence and ignores even a grown watch, so no runaway can drain the shared
6443 // budget. A recent warm `last_poll` is seeded so the "first sight always
6444 // fetches" base case cannot mask the throttle — the only thing that could
6445 // fetch here is the grew-trigger, which the throttle suppresses.
6446 let dir = tempfile::tempdir().unwrap();
6447 github_repo(dir.path());
6448 let bin_dir = tempfile::tempdir().unwrap();
6449 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6450
6451 let svc = WorktreesService::new();
6452 // Warm start with an *empty* watch but a fresh poll time: the registered
6453 // repo then reads as a grown watch, while `last_poll` is recent enough that
6454 // only the grew-trigger — not an elapsed backoff — could drive a fetch.
6455 *svc.pr_warm_start
6456 .lock()
6457 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
6458 watched: vec![],
6459 polled_at: Utc::now(),
6460 });
6461 // Budget over the warn threshold before the poller starts.
6462 svc.rate_limit_cache.replace(RateLimitSnapshot {
6463 graphql: Some(rl_resource(90)),
6464 core: Some(rl_resource(3)),
6465 search: None,
6466 });
6467 svc.registry.set_polling("rust-works", "omni-dev", true);
6468 svc.handle(
6469 "register",
6470 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6471 )
6472 .await
6473 .unwrap();
6474 // `base` far larger than the test, so a fetch could only come from the
6475 // grew-trigger the throttle is meant to suppress.
6476 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
6477
6478 // Let the poller wake on the registration and run a while.
6479 tokio::time::sleep(Duration::from_millis(300)).await;
6480 svc.shutdown().await;
6481 assert_eq!(
6482 gh_spawn_count(&counter),
6483 0,
6484 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
6485 );
6486 }
6487
6488 #[tokio::test]
6489 // Holds the shim guard across awaits; see the note above.
6490 #[allow(clippy::await_holding_lock)]
6491 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
6492 // The diff-and-drop contract: an unchanged poll must not bump, or every
6493 // window re-renders on every tick — the cost this design exists to avoid.
6494 let dir = tempfile::tempdir().unwrap();
6495 github_repo(dir.path());
6496 let bin_dir = tempfile::tempdir().unwrap();
6497 let (fake, _shim) = fake_gh(
6498 bin_dir.path(),
6499 r#"{"data":{"r0":{"b0":{
6500 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6501 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6502 ]}}},
6503 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
6504 }}}}"#,
6505 );
6506 let svc = WorktreesService::new();
6507 svc.handle(
6508 "register",
6509 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6510 )
6511 .await
6512 .unwrap();
6513 // Polling defaults off (#1376): enable it for this repo so the poller/fold
6514 // act on it, as if the user had toggled it on.
6515 svc.registry.set_polling("rust-works", "omni-dev", true);
6516 svc.start_pr_poller_with(
6517 Duration::from_millis(50),
6518 Duration::from_millis(10),
6519 fake.clone(),
6520 );
6521
6522 tokio::time::timeout(Duration::from_secs(30), async {
6523 loop {
6524 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
6525 return;
6526 }
6527 tokio::time::sleep(Duration::from_millis(25)).await;
6528 }
6529 })
6530 .await
6531 .expect("poller should resolve a badge through the fake gh");
6532 // The fake always answers identically, so after the first resolve every
6533 // subsequent poll is a no-change and must leave the generation alone.
6534 let settled = svc.registry.change_generation();
6535 tokio::time::sleep(Duration::from_millis(150)).await;
6536 assert_eq!(
6537 svc.registry.change_generation(),
6538 settled,
6539 "an unchanged poll must not bump the change-notify"
6540 );
6541 svc.shutdown().await;
6542 }
6543
6544 #[tokio::test]
6545 // Holds the shim guard across awaits; see the note above.
6546 #[allow(clippy::await_holding_lock)]
6547 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
6548 // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
6549 // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
6550 // as `pr_none`, and — since the answer never changes — bumps the
6551 // change-notify only for the poll that first delivered it.
6552 let dir = tempfile::tempdir().unwrap();
6553 github_repo(dir.path());
6554 let bin_dir = tempfile::tempdir().unwrap();
6555 let (fake, _shim) = fake_gh(
6556 bin_dir.path(),
6557 r#"{"data":{"r0":{"b0":{
6558 "target":{"oid":"abc","statusCheckRollup":null},
6559 "associatedPullRequests":{"nodes":[]}
6560 }}}}"#,
6561 );
6562 let svc = WorktreesService::new();
6563 svc.handle(
6564 "register",
6565 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6566 )
6567 .await
6568 .unwrap();
6569 // Polling defaults off (#1376): enable it for this repo so the poller/fold
6570 // act on it, as if the user had toggled it on.
6571 svc.registry.set_polling("rust-works", "omni-dev", true);
6572 svc.start_pr_poller_with(
6573 Duration::from_millis(50),
6574 Duration::from_millis(10),
6575 fake.clone(),
6576 );
6577
6578 tokio::time::timeout(Duration::from_secs(30), async {
6579 loop {
6580 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
6581 return;
6582 }
6583 tokio::time::sleep(Duration::from_millis(25)).await;
6584 }
6585 })
6586 .await
6587 .expect("poller should resolve the negative through the fake gh");
6588
6589 // The negative reaches the wire the windows actually read.
6590 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6591 assert_eq!(wt["pr_none"], json!(true));
6592 assert!(wt.get("pr").is_none(), "{wt:?}");
6593
6594 // Identical re-polls of the same negative must not bump.
6595 let settled = svc.registry.change_generation();
6596 tokio::time::sleep(Duration::from_millis(150)).await;
6597 assert_eq!(
6598 svc.registry.change_generation(),
6599 settled,
6600 "an unchanged negative must not bump the change-notify"
6601 );
6602 svc.shutdown().await;
6603 }
6604
6605 #[test]
6606 fn sync_indicator_formats_only_with_upstream() {
6607 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
6608 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
6609 assert_eq!(sync_indicator(None, None), None);
6610 // A partial pair (no real upstream) yields nothing.
6611 assert_eq!(sync_indicator(Some(1), None), None);
6612 }
6613
6614 #[tokio::test]
6615 async fn list_enriches_entries_with_git_status() {
6616 let dir = tempfile::tempdir().unwrap();
6617 let repo = init_repo(dir.path());
6618 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6619 repo.set_head("refs/heads/main").unwrap();
6620
6621 let svc = WorktreesService::new();
6622 svc.handle(
6623 "register",
6624 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6625 )
6626 .await
6627 .unwrap();
6628 let payload = svc.handle("list", Value::Null).await.unwrap();
6629 let windows = windows_of(&payload);
6630 assert_eq!(windows.len(), 1);
6631 assert_eq!(
6632 windows[0].get("branch").and_then(Value::as_str),
6633 Some("main")
6634 );
6635 // No upstream configured → the ahead/behind keys are absent, not zero.
6636 assert!(windows[0].get("ahead").is_none());
6637 assert!(windows[0].get("behind").is_none());
6638 // The main repo name is enriched onto the entry.
6639 assert_eq!(
6640 windows[0].get("main_repo").and_then(Value::as_str),
6641 dir.path().file_name().and_then(|n| n.to_str())
6642 );
6643
6644 // A non-repo folder is still listed, just without a branch or main repo.
6645 let plain = tempfile::tempdir().unwrap();
6646 svc.handle(
6647 "register",
6648 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
6649 )
6650 .await
6651 .unwrap();
6652 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
6653 let w2 = windows
6654 .iter()
6655 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
6656 .unwrap();
6657 assert!(w2.get("branch").is_none());
6658 assert!(w2.get("main_repo").is_none());
6659 }
6660
6661 #[test]
6662 fn window_label_prefers_git_branch_over_title() {
6663 let dir = tempfile::tempdir().unwrap();
6664 let repo = init_repo(dir.path());
6665 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6666 repo.set_head("refs/heads/main").unwrap();
6667 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
6668 let entry = WindowEntry {
6669 key: "k".to_string(),
6670 folders: vec![dir.path().to_path_buf()],
6671 // Both the companion `repo` and `title` are overridden by the
6672 // git-derived main repo name and computed branch.
6673 repo: Some("companion-repo".to_string()),
6674 title: Some("ignored title".to_string()),
6675 pid: None,
6676 last_seen: Utc::now(),
6677 };
6678 // Main checkout: `repo · branch`, and with no upstream there is no sync.
6679 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
6680 }
6681
6682 #[tokio::test]
6683 async fn list_includes_ahead_behind_for_tracking_branch() {
6684 let dir = tempfile::tempdir().unwrap();
6685 let _repo = diverging_repo(dir.path());
6686
6687 let svc = WorktreesService::new();
6688 svc.handle(
6689 "register",
6690 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6691 )
6692 .await
6693 .unwrap();
6694 let payload = svc.handle("list", Value::Null).await.unwrap();
6695 let windows = windows_of(&payload);
6696 // A tracking branch serializes branch plus both divergence counts.
6697 assert_eq!(
6698 windows[0].get("branch").and_then(Value::as_str),
6699 Some("main")
6700 );
6701 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
6702 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
6703 }
6704
6705 #[test]
6706 fn window_label_includes_sync_for_tracking_branch() {
6707 let dir = tempfile::tempdir().unwrap();
6708 let _repo = diverging_repo(dir.path());
6709 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
6710 let entry = WindowEntry {
6711 key: "k".to_string(),
6712 folders: vec![dir.path().to_path_buf()],
6713 repo: Some("companion-repo".to_string()),
6714 title: None,
6715 pid: None,
6716 last_seen: Utc::now(),
6717 };
6718 // A tracking branch appends the `(+ahead -behind)` sync indicator.
6719 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
6720 }
6721
6722 /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
6723 /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
6724 /// <wt_path>`.
6725 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
6726 let commit = repo.find_commit(base).unwrap();
6727 repo.branch(branch, &commit, false).unwrap();
6728 let reference = repo
6729 .find_reference(&format!("refs/heads/{branch}"))
6730 .unwrap();
6731 let mut opts = git2::WorktreeAddOptions::new();
6732 opts.reference(Some(&reference));
6733 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
6734 }
6735
6736 #[test]
6737 fn git_status_marks_linked_worktree_and_names_parent_repo() {
6738 let main_dir = tempfile::tempdir().unwrap();
6739 let repo = init_repo(main_dir.path());
6740 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6741 repo.set_head("refs/heads/main").unwrap();
6742
6743 // A linked worktree checked out on a new `feature` branch, in a
6744 // directory whose basename is deliberately *not* the repo name.
6745 let wt_parent = tempfile::tempdir().unwrap();
6746 let wt_path = wt_parent.path().join("feature-wt");
6747 add_worktree(&repo, a, &wt_path, "feature");
6748
6749 let status = git_status(&wt_path);
6750 assert!(status.is_worktree);
6751 assert_eq!(status.branch.as_deref(), Some("feature"));
6752 // The worktree names its *parent* repo, not its worktree-folder basename.
6753 assert_eq!(
6754 status.main_repo.as_deref(),
6755 main_dir.path().file_name().and_then(|n| n.to_str())
6756 );
6757
6758 // The main checkout resolves the same repo name and is not a worktree.
6759 let main_status = git_status(main_dir.path());
6760 assert!(!main_status.is_worktree);
6761 assert_eq!(main_status.main_repo, status.main_repo);
6762 }
6763
6764 #[test]
6765 fn window_label_marks_worktree_with_fork_glyph() {
6766 let main_dir = tempfile::tempdir().unwrap();
6767 let repo = init_repo(main_dir.path());
6768 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6769 repo.set_head("refs/heads/main").unwrap();
6770 let wt_parent = tempfile::tempdir().unwrap();
6771 let wt_path = wt_parent.path().join("feature-wt");
6772 add_worktree(&repo, a, &wt_path, "feature");
6773
6774 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
6775 let entry = WindowEntry {
6776 key: "k".to_string(),
6777 folders: vec![wt_path],
6778 repo: Some("feature-wt".to_string()),
6779 title: None,
6780 pid: None,
6781 last_seen: Utc::now(),
6782 };
6783 // A worktree line: parent repo, the fork glyph, then the branch (no
6784 // upstream here, so no sync suffix).
6785 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
6786 }
6787
6788 #[test]
6789 fn main_repo_name_derives_from_common_dir() {
6790 // Normal layout: the repo is the directory that contains `.git`.
6791 assert_eq!(
6792 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
6793 Some("omni-dev")
6794 );
6795 // A trailing slash on the common dir does not change the answer.
6796 assert_eq!(
6797 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
6798 Some("omni-dev")
6799 );
6800 // A bare repo: its own directory name, without the `.git` suffix.
6801 assert_eq!(
6802 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
6803 Some("omni-dev")
6804 );
6805 // A `.git` at the filesystem root has no parent name to use.
6806 assert_eq!(main_repo_name(Path::new("/.git")), None);
6807 }
6808
6809 // --- Repo/worktree tree (#1265) ----------------------------------------
6810
6811 /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
6812 /// temporary payload).
6813 fn repos_of(payload: &Value) -> Vec<Value> {
6814 payload
6815 .get("repos")
6816 .and_then(Value::as_array)
6817 .expect("repos array")
6818 .clone()
6819 }
6820
6821 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
6822 Some(GithubIdentity {
6823 owner: owner.to_string(),
6824 name: name.to_string(),
6825 })
6826 }
6827
6828 #[test]
6829 fn github_identity_parses_supported_forms() {
6830 // https / http, with and without the `.git` suffix.
6831 assert_eq!(
6832 github_identity("https://github.com/rust-works/omni-dev.git"),
6833 github("rust-works", "omni-dev")
6834 );
6835 assert_eq!(
6836 github_identity("https://github.com/rust-works/omni-dev"),
6837 github("rust-works", "omni-dev")
6838 );
6839 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
6840 // SCP-like and ssh:// / git:// forms.
6841 assert_eq!(
6842 github_identity("git@github.com:rust-works/omni-dev.git"),
6843 github("rust-works", "omni-dev")
6844 );
6845 assert_eq!(
6846 github_identity("ssh://git@github.com/o/r.git"),
6847 github("o", "r")
6848 );
6849 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
6850 // A trailing slash and surrounding whitespace are tolerated.
6851 assert_eq!(
6852 github_identity(" https://github.com/o/r/ "),
6853 github("o", "r")
6854 );
6855 }
6856
6857 #[test]
6858 fn github_identity_rejects_non_github_and_malformed() {
6859 // Non-GitHub hosts.
6860 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
6861 assert_eq!(github_identity("git@example.com:o/r.git"), None);
6862 // Missing or extra path segments.
6863 assert_eq!(github_identity("https://github.com/onlyowner"), None);
6864 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
6865 assert_eq!(github_identity("https://github.com/"), None);
6866 // Not a URL at all.
6867 assert_eq!(github_identity("not a url"), None);
6868 }
6869
6870 #[test]
6871 fn remote_github_identity_reads_origin_then_falls_back() {
6872 let dir = tempfile::tempdir().unwrap();
6873 let repo = init_repo(dir.path());
6874 // No remotes → None.
6875 assert_eq!(remote_github_identity(&repo), None);
6876 // A non-GitHub origin is not a match.
6877 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
6878 assert_eq!(remote_github_identity(&repo), None);
6879 // A GitHub origin resolves to its identity.
6880 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
6881 .unwrap();
6882 assert_eq!(
6883 remote_github_identity(&repo),
6884 github("rust-works", "omni-dev")
6885 );
6886
6887 // Origin non-GitHub but another remote is GitHub: the fallback loop over
6888 // the remaining remotes finds it.
6889 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
6890 .unwrap();
6891 repo.remote("upstream", "https://github.com/other/proj.git")
6892 .unwrap();
6893 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
6894 }
6895
6896 #[tokio::test]
6897 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
6898 let svc = WorktreesService::new();
6899 // No windows → an empty repo set (not an error), toggle at its default.
6900 assert_eq!(
6901 svc.handle("tree", Value::Null).await.unwrap(),
6902 json!({ "repos": [], "show_closed": true })
6903 );
6904 // A plain non-repo folder is skipped rather than sinking the op.
6905 let plain = tempfile::tempdir().unwrap();
6906 svc.handle(
6907 "register",
6908 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
6909 )
6910 .await
6911 .unwrap();
6912 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
6913 }
6914
6915 #[tokio::test]
6916 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
6917 let main_dir = tempfile::tempdir().unwrap();
6918 let repo = init_repo(main_dir.path());
6919 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6920 repo.set_head("refs/heads/main").unwrap();
6921 // A GitHub origin so the repo carries an identity in the payload.
6922 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
6923 .unwrap();
6924
6925 // A linked worktree on a new `feature` branch, in a directory whose
6926 // basename is deliberately not the repo name.
6927 let wt_parent = tempfile::tempdir().unwrap();
6928 let wt_path = wt_parent.path().join("feature-wt");
6929 add_worktree(&repo, a, &wt_path, "feature");
6930
6931 let svc = WorktreesService::new();
6932 // A window open on the main checkout and one on the linked worktree —
6933 // two windows, but one repo (they must dedupe).
6934 svc.handle(
6935 "register",
6936 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
6937 )
6938 .await
6939 .unwrap();
6940 svc.handle(
6941 "register",
6942 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
6943 )
6944 .await
6945 .unwrap();
6946
6947 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6948 assert_eq!(
6949 repos.len(),
6950 1,
6951 "two worktrees of one repo dedupe: {repos:?}"
6952 );
6953 let repo0 = &repos[0];
6954 // Repo identity is the parent-repo name (not a worktree-folder basename).
6955 assert_eq!(
6956 repo0.get("main_repo").and_then(Value::as_str),
6957 main_dir.path().file_name().and_then(|n| n.to_str())
6958 );
6959 assert_eq!(
6960 repo0.pointer("/github/owner").and_then(Value::as_str),
6961 Some("rust-works")
6962 );
6963 assert_eq!(
6964 repo0.pointer("/github/name").and_then(Value::as_str),
6965 Some("omni-dev")
6966 );
6967 assert!(repo0.get("root").and_then(Value::as_str).is_some());
6968
6969 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
6970 assert_eq!(worktrees.len(), 2);
6971 // Main working tree first: is_main, open, with the main window's key.
6972 let main_wt = &worktrees[0];
6973 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
6974 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
6975 assert_eq!(
6976 main_wt.get("window_key").and_then(Value::as_str),
6977 Some("wm")
6978 );
6979 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6980 // Linked worktree: not main, open via the feature window.
6981 let linked = &worktrees[1];
6982 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
6983 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
6984 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
6985 assert_eq!(
6986 linked.get("branch").and_then(Value::as_str),
6987 Some("feature")
6988 );
6989 }
6990
6991 #[tokio::test]
6992 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
6993 let main_dir = tempfile::tempdir().unwrap();
6994 let repo = init_repo(main_dir.path());
6995 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6996 repo.set_head("refs/heads/main").unwrap();
6997 // No remote at all → the repo carries no `github` identity.
6998 let wt_parent = tempfile::tempdir().unwrap();
6999 let wt_path = wt_parent.path().join("feature-wt");
7000 add_worktree(&repo, a, &wt_path, "feature");
7001
7002 let svc = WorktreesService::new();
7003 // Only the main checkout has a window; the linked worktree has none.
7004 svc.handle(
7005 "register",
7006 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
7007 )
7008 .await
7009 .unwrap();
7010
7011 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7012 assert_eq!(repos.len(), 1);
7013 assert!(repos[0].get("github").is_none(), "no remote → no github");
7014 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7015 let linked = worktrees
7016 .iter()
7017 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
7018 .expect("the linked worktree");
7019 // Enumerated even though no window has it open, and marked closed.
7020 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
7021 assert!(linked.get("window_key").is_none());
7022 }
7023
7024 // --- Close op (#1277) --------------------------------------------------
7025
7026 /// Builds a repo whose main working tree is on `trunk` with one **clean**
7027 /// linked worktree on `feature`, returning the temp dirs (kept alive so the
7028 /// paths stay valid) and the linked worktree path.
7029 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
7030 let main_dir = tempfile::tempdir().unwrap();
7031 let repo = init_repo(main_dir.path());
7032 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7033 repo.set_head("refs/heads/trunk").unwrap();
7034 let wt_parent = tempfile::tempdir().unwrap();
7035 let wt_path = wt_parent.path().join("feature-wt");
7036 add_worktree(&repo, a, &wt_path, "feature");
7037 (main_dir, wt_parent, wt_path)
7038 }
7039
7040 /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
7041 /// repo — the shape a multi-select delete fans out over, and the only one where
7042 /// two prunes share a `.git/worktrees` to race on (#1359).
7043 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
7044 {
7045 let main_dir = tempfile::tempdir().unwrap();
7046 let repo = init_repo(main_dir.path());
7047 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7048 repo.set_head("refs/heads/trunk").unwrap();
7049 let wt_parent = tempfile::tempdir().unwrap();
7050 let first = wt_parent.path().join("first-wt");
7051 let second = wt_parent.path().join("second-wt");
7052 add_worktree(&repo, a, &first, "first");
7053 add_worktree(&repo, a, &second, "second");
7054 (main_dir, wt_parent, first, second)
7055 }
7056
7057 #[tokio::test]
7058 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
7059 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
7060 let svc = Arc::new(WorktreesService::new());
7061
7062 // The multi-select fan-out: one `close` op per target, both in flight at
7063 // once against the one repo's shared admin state. Genuinely concurrent
7064 // even on this single-threaded runtime — each op's prune is a
7065 // `spawn_blocking`, so awaiting its join yields to the other op.
7066 //
7067 // This guards the fan-out end-to-end (both ops complete, neither is
7068 // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
7069 // a race detector for the lock, because it is not one — it passes with the
7070 // guard removed, the two prunes being far too quick to collide reliably.
7071 let close = |path: PathBuf| {
7072 let svc = svc.clone();
7073 async move {
7074 svc.handle(
7075 "close",
7076 json!({ "path": path, "remove": true, "confirmed": true }),
7077 )
7078 .await
7079 }
7080 };
7081 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
7082
7083 assert_eq!(a.unwrap(), json!({ "removed": true }));
7084 assert_eq!(b.unwrap(), json!({ "removed": true }));
7085 assert!(!first.exists());
7086 assert!(!second.exists());
7087 // Both *admin* entries pruned too, not merely the directories — the half
7088 // the two ops contend on.
7089 let repo = Repository::open(main_dir.path()).unwrap();
7090 assert!(repo.worktrees().unwrap().is_empty());
7091 }
7092
7093 #[tokio::test]
7094 async fn concurrent_closes_overlap_their_heartbeat_waits() {
7095 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
7096 let svc = Arc::new(WorktreesService::new());
7097 // Two *different* windows own the two targets — the multi-select shape.
7098 for (key, path) in [("w2", &first), ("w3", &second)] {
7099 svc.handle("register", json!({ "key": key, "folders": [path] }))
7100 .await
7101 .unwrap();
7102 }
7103
7104 let spawn_close = |path: PathBuf| {
7105 let svc = svc.clone();
7106 tokio::spawn(async move {
7107 svc.handle(
7108 "close",
7109 json!({
7110 "path": path,
7111 "remove": true,
7112 "confirmed": true,
7113 "requester_key": "w1",
7114 }),
7115 )
7116 .await
7117 })
7118 };
7119 let a = spawn_close(first.clone());
7120 let b = spawn_close(second.clone());
7121
7122 // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
7123 // *both* windows are told to close while *neither* op has finished, so the
7124 // two multi-second heartbeat waits are in flight at once. Take the guard
7125 // before `await_windows_closed` instead of after and this fails — op B
7126 // would sit on the lock without ever marking w3, restoring exactly the
7127 // N-stacked-waits latency the fan-out exists to remove.
7128 for key in ["w2", "w3"] {
7129 let mut saw_close = false;
7130 for _ in 0..400 {
7131 let hb = svc
7132 .handle("heartbeat", json!({ "key": key }))
7133 .await
7134 .unwrap();
7135 if hb.get("close").and_then(Value::as_bool) == Some(true) {
7136 saw_close = true;
7137 break;
7138 }
7139 tokio::time::sleep(Duration::from_millis(5)).await;
7140 }
7141 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
7142 }
7143 assert!(
7144 !a.is_finished() && !b.is_finished(),
7145 "neither close can have finished: both windows are still registered"
7146 );
7147
7148 // Both windows close; both ops then remove.
7149 for key in ["w2", "w3"] {
7150 svc.handle("unregister", json!({ "key": key }))
7151 .await
7152 .unwrap();
7153 }
7154 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
7155 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
7156 assert!(!first.exists());
7157 assert!(!second.exists());
7158 }
7159
7160 #[tokio::test]
7161 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
7162 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7163 let svc = WorktreesService::new();
7164 // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
7165 // main, no risks → the extension proceeds with no dialog.
7166 let report = svc
7167 .handle("close", json!({ "path": wt_path, "remove": true }))
7168 .await
7169 .unwrap();
7170 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7171 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
7172 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
7173 assert!(report
7174 .get("risks")
7175 .and_then(Value::as_array)
7176 .unwrap()
7177 .is_empty());
7178 // No side effects: the worktree still exists.
7179 assert!(wt_path.exists());
7180 }
7181
7182 #[tokio::test]
7183 async fn close_removes_a_clean_linked_worktree() {
7184 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7185 let svc = WorktreesService::new();
7186 let reply = svc
7187 .handle(
7188 "close",
7189 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7190 )
7191 .await
7192 .unwrap();
7193 assert_eq!(reply, json!({ "removed": true }));
7194 assert!(
7195 !wt_path.exists(),
7196 "the worktree directory should be deleted"
7197 );
7198 }
7199
7200 #[test]
7201 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
7202 // The reorder (#1315) must still fully remove a worktree: both the
7203 // checked-out directory *and* the admin metadata git tracks it by, so it
7204 // no longer appears in `Repository::worktrees()`.
7205 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7206 let admin = main.path().join(".git").join("worktrees").join("feature");
7207 assert!(admin.exists(), "admin metadata should exist before removal");
7208
7209 remove_worktree(&wt_path).unwrap();
7210
7211 assert!(!wt_path.exists(), "the working directory should be gone");
7212 assert!(!admin.exists(), "the admin metadata should be pruned");
7213 let main_repo = Repository::open(main.path()).unwrap();
7214 assert_eq!(
7215 main_repo.worktrees().unwrap().len(),
7216 0,
7217 "git should no longer track the worktree"
7218 );
7219 }
7220
7221 #[test]
7222 fn remove_worktree_recovers_a_half_removed_orphan() {
7223 // The exact #1315 leftover: the old ordering deleted the admin metadata
7224 // first, then failed to rmdir the working tree, orphaning the directory
7225 // with a dangling `.git` gitlink. `remove_worktree` must clean it up
7226 // rather than error with "not a git worktree".
7227 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7228 let admin = main.path().join(".git").join("worktrees").join("feature");
7229 // Simulate the half-removed state: admin gone, directory (+gitlink) left.
7230 std::fs::remove_dir_all(&admin).unwrap();
7231 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
7232 assert!(
7233 Repository::open(&wt_path).is_err(),
7234 "the orphan should not open as a repo"
7235 );
7236
7237 remove_worktree(&wt_path).unwrap();
7238 assert!(
7239 !wt_path.exists(),
7240 "the leftover directory should be removed"
7241 );
7242 }
7243
7244 #[test]
7245 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
7246 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7247 // A live worktree: gitlink resolves → not an orphan.
7248 assert!(!is_orphaned_worktree(&wt_path));
7249 // The main checkout has a `.git` directory → not an orphan.
7250 assert!(!is_orphaned_worktree(main.path()));
7251 // Drop the admin metadata → the gitlink now dangles → orphan.
7252 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
7253 .unwrap();
7254 assert!(is_orphaned_worktree(&wt_path));
7255 }
7256
7257 #[test]
7258 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
7259 let tmp = tempfile::tempdir().unwrap();
7260 let missing = tmp.path().join("gone");
7261 assert!(remove_dir_all_retrying(&missing).is_ok());
7262 }
7263
7264 #[test]
7265 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
7266 use std::io::Error;
7267 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
7268 assert!(
7269 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
7270 "errno {errno} is the concurrent-writer race and must be retried"
7271 );
7272 }
7273 // A hard failure must surface immediately rather than burn the backoff
7274 // waiting for a condition that will never clear.
7275 for errno in [
7276 nix::libc::EACCES,
7277 nix::libc::EPERM,
7278 nix::libc::EROFS,
7279 nix::libc::ENOTDIR,
7280 ] {
7281 assert!(
7282 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
7283 "errno {errno} is permanent and must not be retried"
7284 );
7285 }
7286 // Not from the OS at all, so there is no errno to classify.
7287 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
7288 }
7289
7290 #[test]
7291 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
7292 // Removing a *file* as if it were a directory fails with ENOTDIR: not the
7293 // race, so it must fail on the first attempt with the original cause
7294 // attached, leaving the path untouched.
7295 let tmp = tempfile::tempdir().unwrap();
7296 let file = tmp.path().join("not-a-directory");
7297 std::fs::write(&file, b"x").unwrap();
7298
7299 let mut attempts = 0;
7300 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
7301 attempts += 1;
7302 std::fs::remove_dir_all(&file)
7303 })
7304 .unwrap_err();
7305
7306 assert_eq!(attempts, 1, "a permanent error must not be retried");
7307 assert!(
7308 err.to_string()
7309 .contains("failed to remove worktree directory"),
7310 "unexpected error: {err:#}"
7311 );
7312 assert!(err.source().is_some(), "the io::Error cause is preserved");
7313 assert!(file.exists());
7314 }
7315
7316 #[test]
7317 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
7318 // A writer that never quiesces: every sweep re-finds the directory
7319 // populated. Once the schedule runs out the ENOTEMPTY must surface rather
7320 // than the loop spinning forever.
7321 let tmp = tempfile::tempdir().unwrap();
7322 let mut attempts = 0;
7323 let backoff = [Duration::ZERO, Duration::ZERO];
7324 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
7325 attempts += 1;
7326 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
7327 })
7328 .unwrap_err();
7329
7330 // One attempt per delay, plus the initial one.
7331 assert_eq!(attempts, backoff.len() + 1);
7332 assert!(
7333 err.to_string()
7334 .contains("failed to remove worktree directory"),
7335 "unexpected error: {err:#}"
7336 );
7337 }
7338
7339 #[test]
7340 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
7341 // The #1315 happy path, deterministically: the race clears partway through
7342 // the schedule and the removal then succeeds.
7343 let tmp = tempfile::tempdir().unwrap();
7344 let mut attempts = 0;
7345 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
7346 attempts += 1;
7347 if attempts < 3 {
7348 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
7349 } else {
7350 Ok(())
7351 }
7352 });
7353 assert!(result.is_ok(), "{result:?}");
7354 assert_eq!(attempts, 3);
7355 }
7356
7357 #[test]
7358 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
7359 // A `.git` file that is readable but carries no `gitdir:` pointer is not
7360 // something we may delete.
7361 let tmp = tempfile::tempdir().unwrap();
7362 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
7363 assert!(!is_orphaned_worktree(tmp.path()));
7364 }
7365
7366 #[test]
7367 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
7368 // Neither a repo nor an orphan: refuse it rather than recursively deleting
7369 // whatever directory was passed in.
7370 let tmp = tempfile::tempdir().unwrap();
7371 let plain = tmp.path().join("plain");
7372 std::fs::create_dir(&plain).unwrap();
7373
7374 let err = remove_worktree(&plain).unwrap_err();
7375
7376 assert!(
7377 err.to_string().contains("not a git worktree"),
7378 "unexpected error: {err:#}"
7379 );
7380 assert!(plain.exists(), "a non-worktree path must be left alone");
7381 }
7382
7383 #[test]
7384 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
7385 // Acceptance criterion (#1315): a language server / cargo still writing
7386 // into `target/` as the window closes makes the recursive rmdir race with
7387 // "Directory not empty". A background thread reproduces that by
7388 // repopulating `target/` for a bounded window; removal must retry past it
7389 // and still succeed once the writer stops.
7390 use std::sync::atomic::{AtomicBool, Ordering};
7391 use std::sync::Arc;
7392
7393 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7394 let target = wt_path.join("target");
7395 std::fs::create_dir_all(&target).unwrap();
7396
7397 let stop = Arc::new(AtomicBool::new(false));
7398 let writer_stop = Arc::clone(&stop);
7399 let writer_dir = target;
7400 let writer = std::thread::spawn(move || {
7401 let mut n = 0u64;
7402 // Churn hard for ~400ms (well under the ~2.75s retry budget), then
7403 // stop so a later removal pass finds the directory quiescent.
7404 let deadline = std::time::Instant::now() + Duration::from_millis(400);
7405 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
7406 let nested = writer_dir.join("nested");
7407 // Best-effort: the parent may be mid-deletion — ignore failures.
7408 let _ = std::fs::create_dir_all(&nested);
7409 let _ = std::fs::write(nested.join(format!("artifact-{n}.tmp")), b"x");
7410 n += 1;
7411 }
7412 });
7413
7414 let result = remove_worktree(&wt_path);
7415 stop.store(true, Ordering::Relaxed);
7416 writer.join().unwrap();
7417
7418 assert!(
7419 result.is_ok(),
7420 "removal should retry past the writer: {result:?}"
7421 );
7422 assert!(!wt_path.exists(), "the worktree directory should be gone");
7423 }
7424
7425 #[tokio::test]
7426 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
7427 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7428 // An untracked file in the worktree would be lost on removal.
7429 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
7430
7431 let svc = WorktreesService::new();
7432 let report = svc
7433 .handle("close", json!({ "path": wt_path, "remove": true }))
7434 .await
7435 .unwrap();
7436 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7437 assert!(
7438 risks
7439 .iter()
7440 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
7441 "expected an untracked risk: {report}"
7442 );
7443 // Still removable — the risk only means "confirm first", not "refuse".
7444 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7445 // The unconfirmed check has no side effects.
7446 assert!(wt_path.exists());
7447 }
7448
7449 #[tokio::test]
7450 async fn close_confirmed_removes_a_dirty_worktree() {
7451 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7452 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
7453 let svc = WorktreesService::new();
7454 // With confirmation, the risks are overridden and removal proceeds.
7455 let reply = svc
7456 .handle(
7457 "close",
7458 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7459 )
7460 .await
7461 .unwrap();
7462 assert_eq!(reply, json!({ "removed": true }));
7463 assert!(!wt_path.exists());
7464 }
7465
7466 #[tokio::test]
7467 async fn close_refuses_to_remove_the_main_working_tree() {
7468 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
7469 let svc = WorktreesService::new();
7470 // Phase 1: the main tree reports not-removable, marked main.
7471 let report = svc
7472 .handle("close", json!({ "path": main.path(), "remove": true }))
7473 .await
7474 .unwrap();
7475 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
7476 assert_eq!(
7477 report.get("removable").and_then(Value::as_bool),
7478 Some(false)
7479 );
7480 // Phase 2: even a confirmed delete of the main tree is refused
7481 // defensively, and the directory is untouched.
7482 assert!(svc
7483 .handle(
7484 "close",
7485 json!({ "path": main.path(), "remove": true, "confirmed": true }),
7486 )
7487 .await
7488 .is_err());
7489 assert!(main.path().exists());
7490 }
7491
7492 #[tokio::test]
7493 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
7494 // The case a naive impl would wrongly protect: a linked worktree checked
7495 // out on `main` (the default branch) is *still a linked worktree*, so it
7496 // is fully deletable — and `main` survives (removal never deletes a branch).
7497 let main_dir = tempfile::tempdir().unwrap();
7498 let repo = init_repo(main_dir.path());
7499 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7500 repo.set_head("refs/heads/trunk").unwrap();
7501 let wt_parent = tempfile::tempdir().unwrap();
7502 let wt_path = wt_parent.path().join("main-wt");
7503 add_worktree(&repo, a, &wt_path, "main");
7504
7505 let svc = WorktreesService::new();
7506 let reply = svc
7507 .handle(
7508 "close",
7509 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7510 )
7511 .await
7512 .unwrap();
7513 assert_eq!(reply, json!({ "removed": true }));
7514 assert!(!wt_path.exists());
7515 // The `main` branch is untouched by the worktree removal.
7516 assert!(
7517 repo.find_branch("main", git2::BranchType::Local).is_ok(),
7518 "the default branch must survive worktree removal"
7519 );
7520 }
7521
7522 #[tokio::test]
7523 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
7524 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7525 let svc = WorktreesService::new();
7526 // First removal succeeds.
7527 svc.handle(
7528 "close",
7529 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7530 )
7531 .await
7532 .unwrap();
7533 // A second confirmed close of the now-missing path is a clean success,
7534 // not an error (a stale snapshot must not crash).
7535 let reply = svc
7536 .handle(
7537 "close",
7538 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7539 )
7540 .await
7541 .unwrap();
7542 assert_eq!(reply, json!({ "removed": true }));
7543 }
7544
7545 #[tokio::test]
7546 async fn close_safety_check_detects_detached_head_unreachable_commits() {
7547 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7548 // In the worktree, commit onto a detached HEAD so the new commit is
7549 // reachable from no ref — it would be GC'd on removal.
7550 let wt_repo = Repository::open(&wt_path).unwrap();
7551 let parent_oid = wt_repo.head().unwrap().target().unwrap();
7552 let parent = wt_repo.find_commit(parent_oid).unwrap();
7553 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
7554 wt_repo.set_head_detached(orphan).unwrap();
7555
7556 let svc = WorktreesService::new();
7557 let report = svc
7558 .handle("close", json!({ "path": wt_path, "remove": true }))
7559 .await
7560 .unwrap();
7561 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7562 assert!(
7563 risks
7564 .iter()
7565 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
7566 "expected an unreachable-commits risk: {report}"
7567 );
7568 }
7569
7570 #[tokio::test]
7571 async fn close_self_close_removes_when_the_requester_owns_the_target() {
7572 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7573 let svc = WorktreesService::new();
7574 // The requesting window itself has the worktree open: it is the only
7575 // owning window, so there is nothing to wait on — remove and reply, and
7576 // the extension closes its own window on `ok`.
7577 svc.handle(
7578 "register",
7579 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
7580 )
7581 .await
7582 .unwrap();
7583 let reply = svc
7584 .handle(
7585 "close",
7586 json!({
7587 "path": wt_path,
7588 "remove": true,
7589 "confirmed": true,
7590 "requester_key": "w1",
7591 }),
7592 )
7593 .await
7594 .unwrap();
7595 assert_eq!(reply, json!({ "removed": true }));
7596 assert!(!wt_path.exists());
7597 }
7598
7599 #[tokio::test]
7600 async fn close_safety_check_surfaces_the_owning_window() {
7601 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7602 let svc = WorktreesService::new();
7603 // A multi-root window owns the target: the report surfaces its key and
7604 // folder count so the extension can warn "all N folders will close".
7605 svc.handle(
7606 "register",
7607 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
7608 )
7609 .await
7610 .unwrap();
7611 let report = svc
7612 .handle("close", json!({ "path": wt_path, "remove": true }))
7613 .await
7614 .unwrap();
7615 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
7616 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
7617 assert_eq!(
7618 report.get("window_folder_count").and_then(Value::as_u64),
7619 Some(2)
7620 );
7621 }
7622
7623 #[tokio::test]
7624 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
7625 let svc = WorktreesService::new();
7626 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
7627 .await
7628 .unwrap();
7629 // No directive → a plain `{ known: true }`, byte-identical to before.
7630 assert_eq!(
7631 svc.handle("heartbeat", json!({ "key": "w1" }))
7632 .await
7633 .unwrap(),
7634 json!({ "known": true })
7635 );
7636 // Marked → the next heartbeat carries `close: true`, exactly once.
7637 svc.registry.mark_close_pending("w1");
7638 assert_eq!(
7639 svc.handle("heartbeat", json!({ "key": "w1" }))
7640 .await
7641 .unwrap(),
7642 json!({ "known": true, "close": true })
7643 );
7644 assert_eq!(
7645 svc.handle("heartbeat", json!({ "key": "w1" }))
7646 .await
7647 .unwrap(),
7648 json!({ "known": true })
7649 );
7650 }
7651
7652 #[tokio::test]
7653 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
7654 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7655 let svc = Arc::new(WorktreesService::new());
7656 // A *different* window (not the requester) owns the target.
7657 svc.handle(
7658 "register",
7659 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
7660 )
7661 .await
7662 .unwrap();
7663
7664 // Drive the destructive close concurrently: it marks w2 to close and
7665 // waits for it to unregister before removing.
7666 let svc2 = svc.clone();
7667 let path = wt_path.clone();
7668 let close = tokio::spawn(async move {
7669 svc2.handle(
7670 "close",
7671 json!({
7672 "path": path,
7673 "remove": true,
7674 "confirmed": true,
7675 "requester_key": "w1",
7676 }),
7677 )
7678 .await
7679 });
7680
7681 // Simulate w2's extension: its next heartbeat sees `close: true`, so it
7682 // closes its window and unregisters. Poll until the directive appears.
7683 let mut saw_close = false;
7684 for _ in 0..200 {
7685 let hb = svc
7686 .handle("heartbeat", json!({ "key": "w2" }))
7687 .await
7688 .unwrap();
7689 if hb.get("close").and_then(Value::as_bool) == Some(true) {
7690 saw_close = true;
7691 svc.handle("unregister", json!({ "key": "w2" }))
7692 .await
7693 .unwrap();
7694 break;
7695 }
7696 tokio::time::sleep(Duration::from_millis(5)).await;
7697 }
7698 assert!(saw_close, "w2 should have been told to close");
7699
7700 // Once w2 has unregistered, the close op removes the worktree.
7701 let reply = close.await.unwrap().unwrap();
7702 assert_eq!(reply, json!({ "removed": true }));
7703 assert!(!wt_path.exists());
7704 }
7705
7706 #[tokio::test]
7707 async fn await_windows_closed_times_out_when_a_window_never_closes() {
7708 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7709 let svc = WorktreesService::new();
7710 svc.handle(
7711 "register",
7712 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
7713 )
7714 .await
7715 .unwrap();
7716 // The owning window never unregisters: the wait gives up (with a short
7717 // timeout here) rather than block, and names the still-open window.
7718 let err = await_windows_closed(
7719 &svc.registry,
7720 &wt_path,
7721 Some("w1"),
7722 Duration::from_millis(150),
7723 Duration::from_millis(25),
7724 )
7725 .await
7726 .unwrap_err();
7727 assert!(
7728 err.to_string().contains("w2"),
7729 "error names the window: {err}"
7730 );
7731 // The requester itself is excluded, so a self-only owner returns at once.
7732 await_windows_closed(
7733 &svc.registry,
7734 &wt_path,
7735 Some("w2"),
7736 Duration::from_millis(150),
7737 Duration::from_millis(25),
7738 )
7739 .await
7740 .unwrap();
7741 }
7742
7743 #[tokio::test]
7744 async fn close_window_without_remove_replies_closed_and_never_deletes() {
7745 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
7746 let svc = WorktreesService::new();
7747 // "Close Window" on the main tree: no git inspection, no removal.
7748 let reply = svc
7749 .handle("close", json!({ "path": main.path(), "remove": false }))
7750 .await
7751 .unwrap();
7752 assert_eq!(reply, json!({ "closed": true }));
7753 assert!(main.path().exists());
7754 }
7755
7756 #[tokio::test]
7757 async fn close_safety_check_flags_modified_tracked_files() {
7758 // A tracked file, checked out into the linked worktree, then modified —
7759 // its content is lost on removal, so it is a `dirty` risk (distinct from
7760 // the untracked case).
7761 let main_dir = tempfile::tempdir().unwrap();
7762 let repo = init_repo(main_dir.path());
7763 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
7764 repo.set_head("refs/heads/trunk").unwrap();
7765 let wt_parent = tempfile::tempdir().unwrap();
7766 let wt_path = wt_parent.path().join("feature-wt");
7767 add_worktree(&repo, a, &wt_path, "feature");
7768 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
7769
7770 let svc = WorktreesService::new();
7771 let report = svc
7772 .handle("close", json!({ "path": wt_path, "remove": true }))
7773 .await
7774 .unwrap();
7775 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7776 assert!(
7777 risks
7778 .iter()
7779 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
7780 "expected a dirty risk: {report}"
7781 );
7782 }
7783
7784 #[tokio::test]
7785 async fn close_safety_check_flags_an_in_progress_operation() {
7786 // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
7787 // non-Clean (interrupted merge) state — its progress is lost on removal.
7788 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7789 let wt_repo = Repository::open(&wt_path).unwrap();
7790 let head = wt_repo.head().unwrap().target().unwrap();
7791 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
7792 assert_ne!(wt_repo.state(), RepositoryState::Clean);
7793
7794 let svc = WorktreesService::new();
7795 let report = svc
7796 .handle("close", json!({ "path": wt_path, "remove": true }))
7797 .await
7798 .unwrap();
7799 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7800 assert!(
7801 risks
7802 .iter()
7803 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
7804 "expected an in-progress risk: {report}"
7805 );
7806 }
7807
7808 #[tokio::test]
7809 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
7810 // A linked worktree on `feature`, which tracks `origin/feature` and is one
7811 // commit ahead. The unpushed commit is INFO (the branch — and thus the
7812 // commit — survives removal), never a blocking risk.
7813 let main_dir = tempfile::tempdir().unwrap();
7814 let repo = init_repo(main_dir.path());
7815 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7816 repo.set_head("refs/heads/trunk").unwrap();
7817 let a_commit = repo.find_commit(a).unwrap();
7818 repo.branch("feature", &a_commit, false).unwrap();
7819 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
7820 .unwrap();
7821 // `feature` advances one commit past `origin/feature`.
7822 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
7823 drop(a_commit);
7824 let mut cfg = repo.config().unwrap();
7825 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
7826 .unwrap();
7827 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
7828 .unwrap();
7829 cfg.set_str("branch.feature.remote", "origin").unwrap();
7830 cfg.set_str("branch.feature.merge", "refs/heads/feature")
7831 .unwrap();
7832 // A worktree on the existing `feature` branch (not created fresh, so it
7833 // keeps the ahead-of-upstream divergence).
7834 let wt_parent = tempfile::tempdir().unwrap();
7835 let wt_path = wt_parent.path().join("feature-wt");
7836 let reference = repo.find_reference("refs/heads/feature").unwrap();
7837 let mut opts = git2::WorktreeAddOptions::new();
7838 opts.reference(Some(&reference));
7839 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
7840
7841 let svc = WorktreesService::new();
7842 let report = svc
7843 .handle("close", json!({ "path": wt_path, "remove": true }))
7844 .await
7845 .unwrap();
7846 // Unpushed commits appear as `info`, and the worktree is still cleanly
7847 // removable with no blocking risks.
7848 let info = report.get("info").and_then(Value::as_array).unwrap();
7849 assert!(
7850 info.iter()
7851 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
7852 "expected an unpushed info note: {report}"
7853 );
7854 assert!(
7855 report
7856 .get("risks")
7857 .and_then(Value::as_array)
7858 .unwrap()
7859 .is_empty(),
7860 "unpushed commits alone must not block: {report}"
7861 );
7862 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7863 }
7864
7865 #[tokio::test]
7866 async fn close_safety_check_ignores_gitignored_files() {
7867 // With `.gitignore` committed, an ignored artifact is the only worktree
7868 // change — it must not count as untracked (it is regenerable), so the
7869 // worktree stays cleanly removable with no risks.
7870 let main_dir = tempfile::tempdir().unwrap();
7871 let repo = init_repo(main_dir.path());
7872 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
7873 repo.set_head("refs/heads/trunk").unwrap();
7874 let wt_parent = tempfile::tempdir().unwrap();
7875 let wt_path = wt_parent.path().join("feature-wt");
7876 add_worktree(&repo, a, &wt_path, "feature");
7877 std::fs::create_dir(wt_path.join("build")).unwrap();
7878 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
7879
7880 let svc = WorktreesService::new();
7881 let report = svc
7882 .handle("close", json!({ "path": wt_path, "remove": true }))
7883 .await
7884 .unwrap();
7885 assert!(
7886 report
7887 .get("risks")
7888 .and_then(Value::as_array)
7889 .unwrap()
7890 .is_empty(),
7891 "a gitignored file must not create a risk: {report}"
7892 );
7893 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7894 }
7895
7896 #[tokio::test]
7897 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
7898 // The phase-1 check on a path that no longer exists reports it removable
7899 // with no risks (so the idempotent execute proceeds with no dialog).
7900 let svc = WorktreesService::new();
7901 let report = svc
7902 .handle(
7903 "close",
7904 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
7905 )
7906 .await
7907 .unwrap();
7908 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7909 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
7910 assert!(report
7911 .get("risks")
7912 .and_then(Value::as_array)
7913 .unwrap()
7914 .is_empty());
7915 let info = report.get("info").and_then(Value::as_array).unwrap();
7916 assert!(info
7917 .iter()
7918 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
7919 }
7920
7921 #[tokio::test]
7922 async fn close_refuses_a_locked_worktree() {
7923 // A locked worktree (git worktree lock) must be refused, not forced past
7924 // (failure mode #6), and left on disk.
7925 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7926 let main_repo = Repository::open(main.path()).unwrap();
7927 main_repo
7928 .find_worktree("feature")
7929 .unwrap()
7930 .lock(Some("under test"))
7931 .unwrap();
7932
7933 let svc = WorktreesService::new();
7934 let err = svc
7935 .handle(
7936 "close",
7937 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7938 )
7939 .await
7940 .unwrap_err();
7941 assert!(
7942 err.to_string().contains("locked"),
7943 "expected a locked error: {err}"
7944 );
7945 assert!(wt_path.exists(), "a locked worktree must not be removed");
7946 }
7947
7948 #[tokio::test]
7949 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
7950 // A detached HEAD that still sits on a commit a branch points to loses
7951 // nothing on removal, so it must NOT produce an unreachable-commits risk
7952 // (the false-positive the reachability walk guards against).
7953 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7954 let wt_repo = Repository::open(&wt_path).unwrap();
7955 // The worktree is on `feature`; detach HEAD onto its current tip, which
7956 // the `feature` branch still references.
7957 let tip = wt_repo.head().unwrap().target().unwrap();
7958 wt_repo.set_head_detached(tip).unwrap();
7959 assert!(wt_repo.head_detached().unwrap());
7960
7961 let svc = WorktreesService::new();
7962 let report = svc
7963 .handle("close", json!({ "path": wt_path, "remove": true }))
7964 .await
7965 .unwrap();
7966 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7967 assert!(
7968 !risks
7969 .iter()
7970 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
7971 "a detached HEAD reachable from a branch must not be flagged: {report}"
7972 );
7973 }
7974
7975 #[test]
7976 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
7977 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7978 let main_repo = Repository::open(main.path()).unwrap();
7979 // The real linked worktree resolves to its registered name.
7980 assert_eq!(
7981 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
7982 "feature"
7983 );
7984 // A path that is not one of this repo's worktrees is the defensive
7985 // "not registered" error (the guard behind removal).
7986 let err =
7987 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
7988 assert!(
7989 err.to_string().contains("not registered"),
7990 "expected a not-registered error: {err}"
7991 );
7992 }
7993
7994 #[test]
7995 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
7996 // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
7997 // rather than sinking the whole safety check.
7998 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7999 let repo = Repository::open(&wt_path).unwrap();
8000 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
8001 // Confirm the corruption actually breaks status enumeration, so the
8002 // count is exercising the error-degradation branch (not an empty repo).
8003 assert!(
8004 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
8005 "a corrupt index should make statuses() fail"
8006 );
8007 assert_eq!(count_dirty_untracked(&repo), (0, 0));
8008 }
8009}