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