Skip to main content

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(&nothing), "(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}