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
33mod geometry;
34
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::process::{Command, Stdio};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::{Arc, Mutex, PoisonError};
40use std::time::{Duration, Instant};
41
42use anyhow::{anyhow, bail, Context, Result};
43use chrono::{DateTime, Utc};
44
45use crate::git::worktree_rebase::{self, Selection};
46use crate::github_rate_limit::{
47    resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
48};
49use crate::pr_status::{
50    EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
51};
52use async_trait::async_trait;
53use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
54use serde::{Deserialize, Serialize};
55use serde_json::{json, Value};
56use tokio::sync::watch;
57use tokio::sync::Mutex as AsyncMutex;
58use tokio::task::JoinHandle;
59use tokio_util::sync::CancellationToken;
60
61use crate::daemon::service::{
62    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
63};
64use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
65
66/// The worktrees service name (the control-socket routing key).
67pub const SERVICE_NAME: &str = "worktrees";
68
69/// The tray submenu title.
70const SUBMENU_TITLE: &str = "Worktrees";
71
72/// Environment override for the VS Code launcher used by the "focus" tray
73/// action, for when the daemon runs under launchd with a minimal `PATH`.
74const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
75
76/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
77/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
78const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
79
80/// Default cadence at which the background task recomputes the tray menu snapshot
81/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
82/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
83/// the GUI thread (which would peg a core and stall shutdown — the #1186
84/// regression); the interval only governs how stale that cached branch/sync state
85/// may be when the menu is opened.
86///
87/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
88/// walk that the subscription-stream coalescing (#1303) never touched — it was
89/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
90/// menu open-latency unchanged (the cache still serves instantly).
91const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
92
93/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
94/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
95fn menu_refresh_interval() -> Duration {
96    crate::daemon::server::duration_secs_from_env(
97        ENV_MENU_REFRESH_INTERVAL,
98        DEFAULT_MENU_REFRESH_INTERVAL,
99    )
100}
101
102/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
103/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
104/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
105const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
106
107/// Default cadence for the PR badge poller while CI is in flight (#1337).
108///
109/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
110/// watching a run — which is exactly this situation. It is affordable because the
111/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
112/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
113/// while something is actually pending.
114const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
115
116/// The ceiling the poller backs off to once every badge is terminal.
117///
118/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
119/// The 30-minute figure is the cross-tool consensus for background PR polling
120/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
121/// defaults). The backoff exists for battery and wakeups rather than budget — at
122/// 1 point per poll the budget never binds.
123const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
124
125/// How long after fresh work (a push, or an added target) the poller holds its
126/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
127/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
128/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
129/// is watched more cheaply. See [`next_pr_poll_delay`].
130const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
131
132/// The ceiling the poller escalates to *while still pending* once
133/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
134/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
135/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
136/// at ~50 calls instead of ~120.
137const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
138
139/// The cadence floor the poller is held to while the shared GitHub budget is
140/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
141/// Five minutes makes the daemon's contribution to an already-strained budget
142/// negligible while still recovering promptly once the window resets — a pause in
143/// all but name. See [`budget_throttled_delay`].
144const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
145
146/// Environment override for [`pr_debounce_interval`] — the settle window the PR
147/// poller waits after the change-notify fires before snapshotting (whole seconds;
148/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
149const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
150
151/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
152///
153/// A VS Code restart unregisters then re-registers its windows one-by-one over
154/// several seconds, each bump waking the poller; a daemon restart re-registers the
155/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
156/// storm into **one** fetch on the final watch set instead of one per window.
157const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
158
159/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
160/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
161fn pr_poll_interval() -> Duration {
162    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
163}
164
165/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
166/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
167fn pr_debounce_interval() -> Duration {
168    crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
169}
170
171/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
172/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
173/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
174const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
175
176/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
177/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
178/// the extension's transient badge fallback — from the daemon means N windows now
179/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
180const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
181
182/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
183/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
184/// `PullRequest` shape unchanged.
185const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
186
187/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
188/// (parity with the extension's `PR_LIST_LIMIT`).
189const OPEN_PR_LIST_LIMIT: &str = "100";
190
191/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
192/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
193fn open_pr_ttl() -> Duration {
194    crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
195}
196
197/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
198/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
199/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
200const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
201
202/// Default cadence for the GitHub rate-limit monitor (#1375).
203///
204/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
205/// spends nothing against any budget — so unlike the PR poller this cadence has no
206/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
207/// keeps `daemon status` current enough to catch a slow drain.
208const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
209
210/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
211/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
212fn rate_limit_poll_interval() -> Duration {
213    crate::daemon::server::duration_secs_from_env(
214        ENV_RATE_LIMIT_POLL_INTERVAL,
215        DEFAULT_RATE_LIMIT_POLL_INTERVAL,
216    )
217}
218
219/// A running background menu-refresh task and the token that stops it.
220struct RefreshTask {
221    /// Cancelled by `shutdown` to end the refresh loop.
222    token: CancellationToken,
223    /// The spawned loop, awaited on shutdown so it fully unwinds.
224    handle: JoinHandle<()>,
225}
226
227/// Whether this tick should spend a `gh` call.
228///
229/// The poller wakes far more often than it fetches — waking is a cached snapshot
230/// read, fetching is a subprocess and a network round trip. Two things justify the
231/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
232/// a push, invisible to the change-notify, so only looking finds it), or the
233/// **backoff elapsed** and it is simply time to look again.
234///
235/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
236/// worktree going away, a lease lapsing, or a TTL reap can never change any
237/// **surviving** badge, so `grew` is false and the poll skips — see
238/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
239///
240/// Pure so the policy is testable directly: from outside, the only evidence of it
241/// is *when* a subprocess runs, which a test cannot pin down without either flaking
242/// or passing for the wrong reason.
243fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
244    // `map_or(true, ..)` rather than `is_none_or`: the latter is stable only
245    // since 1.82 and this crate's MSRV is 1.80.
246    grew || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
247}
248
249/// Whether `next` holds a watch the poller has not already resolved for its
250/// current upstream — an **addition** (a new (repo, branch) target) or an
251/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
252/// reports). Either warrants asking GitHub *now*.
253///
254/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
255/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
256/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
257/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
258/// exactly the cached verdict, and the badge stays correctly stale through
259/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
260/// network call (#1389, fix 3).
261///
262/// Pure so the fetch trigger is testable without driving a live subprocess.
263fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
264    next.iter().any(|w| !prev.contains(w))
265}
266
267/// The next PR-poll delay.
268///
269/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
270///   — nothing is expected to change, so this is a slow liveness heartbeat. A failed
271///   poll passes `pending: false`, so a persistent failure backs off here rather
272///   than being retried hard.
273/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
274///   [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
275///   `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
276///   run used to pin `base` for its whole duration (360 calls/hour); escalating
277///   *within* pending caps that while a verdict arriving a cadence-tick late stays
278///   invisible in the tray. `since_moved` is time since fresh work was last seen (a
279///   push or an added target), or `None` when nothing has moved yet — treated as
280///   past the fast window so a stale-from-boot pending state does not pin `base`.
281///
282/// A pure function rather than copies inline, because the cadence is only
283/// observable from outside as timing, which a test cannot assert without flaking.
284fn next_pr_poll_delay(
285    current: Duration,
286    base: Duration,
287    pending: bool,
288    since_moved: Option<Duration>,
289) -> Duration {
290    if !pending {
291        return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
292    }
293    match since_moved {
294        // Fresh work: watch it closely at `base` while it is likely to resolve.
295        Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
296        // Still pending well after the move (a long CI run, or a zombie suite):
297        // escalate so the cadence stops burning, bounded below the terminal ceiling.
298        _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
299    }
300}
301
302/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
303///
304/// The daemon is the **single** `gh` choke point for every open window, so this is
305/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
306/// any tracked resource is at/over
307/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
308/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
309/// the budget the whole machine shares — structurally, not just by convention. Below
310/// the threshold (or with no reading yet) the delay is returned unchanged.
311///
312/// Pure so the throttle is testable without a live rate-limit poll.
313fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
314    if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
315        delay.max(BUDGET_THROTTLE_INTERVAL)
316    } else {
317        delay
318    }
319}
320
321/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
322/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
323/// threshold upward since the previous reading (or is already over on the first
324/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
325/// per crossing rather than every poll while usage stays high.
326///
327/// Pure so the policy is testable without driving a live poll.
328fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
329    let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
330    // Per-resource so a *different* resource crossing (while another recovers) is
331    // still caught — `graphql` dropping below while `core` climbs over would look
332    // unchanged to a whole-snapshot `over_warn` comparison.
333    [
334        (prev.and_then(|p| p.graphql), next.graphql),
335        (prev.and_then(|p| p.core), next.core),
336        (prev.and_then(|p| p.search), next.search),
337    ]
338    .into_iter()
339    .any(|(before, after)| over(after) && !over(before))
340}
341
342/// A running background PR-badge poll task and the token that stops it.
343struct PollerTask {
344    /// Cancelled by `shutdown` to end the poll loop.
345    token: CancellationToken,
346    /// The spawned loop, awaited on shutdown so it fully unwinds.
347    handle: JoinHandle<()>,
348}
349
350/// One thing the PR poller watches: a badge target and the commit its upstream
351/// points at.
352///
353/// The upstream OID is what makes a **push** observable to the poller. A window
354/// opening bumps the registry's change-notify, but nothing notifies the daemon
355/// when you push — so the poller compares this against the previous tick's and
356/// treats an added target or a moved upstream as "go and ask now" (see
357/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
358/// very thing that starts the CI run a badge reports, so the upstream must be here.
359///
360/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
361/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
362/// call, and the badge stays correctly stale through
363/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
364/// comparison, no network — until the branch is actually pushed. Equality is thus
365/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
366#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
367struct PrWatch {
368    /// The (repo, branch) to resolve a badge for.
369    target: PrTarget,
370    /// That branch's upstream tip, or `None` when it tracks no upstream.
371    upstream_sha: Option<String>,
372}
373
374/// Extracts what the poller watches — the badge targets and their local heads and
375/// upstream tips — from a `tree` snapshot.
376///
377/// Reading them back off the snapshot — rather than walking git again — means the
378/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
379/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
380/// out of its way to remove. Only GitHub repos with a branch contribute; the result
381/// is sorted and deduped so N worktrees of one repo on one branch ask once.
382fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
383    let mut out = Vec::new();
384    for repo in snapshot
385        .get("repos")
386        .and_then(Value::as_array)
387        .into_iter()
388        .flatten()
389    {
390        // The zero-`gh` guarantee (#1376): a repo the user has not enabled
391        // contributes no watch, so the poll's `gh api graphql` never mentions it.
392        // The snapshot this reads is already `stamp_polling`-stamped, so this one
393        // check is the single filter point — default-off means an absent flag skips.
394        if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
395            continue;
396        }
397        let Some(github) = repo.get("github") else {
398            continue;
399        };
400        let (Some(owner), Some(name)) = (
401            github.get("owner").and_then(Value::as_str),
402            github.get("name").and_then(Value::as_str),
403        ) else {
404            continue;
405        };
406        for wt in repo
407            .get("worktrees")
408            .and_then(Value::as_array)
409            .into_iter()
410            .flatten()
411        {
412            if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
413                out.push(PrWatch {
414                    upstream_sha: wt
415                        .get("upstream_sha")
416                        .and_then(Value::as_str)
417                        .map(str::to_string),
418                    target: PrTarget {
419                        owner: owner.to_string(),
420                        name: name.to_string(),
421                        branch: branch.to_string(),
422                    },
423                });
424            }
425        }
426    }
427    out.sort();
428    out.dedup();
429    out
430}
431
432/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
433/// without the heads.
434#[cfg(test)]
435fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
436    pr_watch_from_snapshot(snapshot)
437        .into_iter()
438        .map(|w| w.target)
439        .collect()
440}
441
442/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
443pub struct WorktreesService {
444    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
445    /// the background menu-refresh task can read it off the main thread.
446    registry: Arc<WorktreesRegistry>,
447    /// The most recent tray menu snapshot, recomputed off the main thread by
448    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
449    /// of this so it never blocks on git enrichment. `None` until the first
450    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
451    /// which case `menu()` falls back to a one-off inline compute.
452    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
453    /// The background refresh task, once started (`None` in tests / no runtime).
454    refresh: Mutex<Option<RefreshTask>>,
455    /// PR badges resolved by the background poller and read by the tree snapshot
456    /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
457    /// share the one cache. Empty until the first poll lands — and always empty
458    /// when no poller runs (unit tests), in which case the tree simply carries no
459    /// `pr` field, exactly as a pre-#1337 daemon did.
460    pr_cache: Arc<PrStatusCache>,
461    /// The background PR-badge poll task, once started (`None` in tests / no
462    /// runtime).
463    poller: Mutex<Option<PollerTask>>,
464    /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
465    /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
466    /// poll task, the tray refresh, and the daemon's registry share the one cache.
467    /// Empty until the first poll lands; the daemon hands a clone to the registry
468    /// so `status` can report machine-wide GitHub budget usage.
469    ///
470    /// [`rate-limit poller`]: Self::start_rate_limit_poller
471    rate_limit_cache: Arc<RateLimitCache>,
472    /// The background rate-limit poll task, once started (`None` in tests / no
473    /// runtime).
474    rate_limit_poller: Mutex<Option<PollerTask>>,
475    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
476    /// through, so N open windows perform **one** `build_tree` per tick instead
477    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
478    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
479    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
480    tree_cache: Arc<TreeSnapshotCache>,
481    /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
482    ///
483    /// The extension fans a multi-select delete out into one `close` op per
484    /// target, so two executes can reach the prune at once. Their heartbeat waits
485    /// overlap freely — that is the point — but the prunes themselves should not:
486    /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
487    /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
488    /// read a directory a sibling is midway through removing from, and `git2`
489    /// promises nothing about that. Precautionary rather than a fix for an
490    /// observed corruption — the window is narrow enough that it has not been
491    /// reproduced — but serializing costs nothing measurable (the prune is a
492    /// directory delete; the wait it follows is seconds) and keeps the fan-out
493    /// safe at the source rather than relying on every caller to stay sequential.
494    ///
495    /// A `tokio` mutex rather than a `std` one: it is held across the
496    /// `spawn_blocking` join, which is an `.await`.
497    prune_lock: tokio::sync::Mutex<()>,
498    /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
499    /// choice survives a daemon restart. `None` disables persistence entirely —
500    /// the default from [`new`](Self::new), which keeps the bare service cheap
501    /// and I/O-free for unit tests; the daemon wires it via
502    /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
503    /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
504    /// briefly and never held across an `.await`.
505    polling_prefs_path: Mutex<Option<PathBuf>>,
506    /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
507    /// survive a daemon restart and the poller can skip its immediate re-poll when
508    /// they are still fresh. `None` disables persistence — the default from
509    /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
510    /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
511    /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
512    pr_cache_path: Mutex<Option<PathBuf>>,
513    /// The warm-start state restored from the persisted cache (#1389, fix 4),
514    /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
515    /// spawns. `None` on a cold start (no file, or persistence disabled), in which
516    /// case the poller does its normal first fetch.
517    pr_warm_start: Mutex<Option<PrWarmStart>>,
518    /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
519    /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
520    /// dedupe to one counted `gh` per repo instead of one per window. Behind an
521    /// `Arc` for parity with the other caches.
522    open_pr_cache: Arc<OpenPrCache>,
523    /// Where the windows the **last** `reposition` moved were sitting beforehand,
524    /// so `reposition-undo` can put them back (#1407).
525    ///
526    /// Exactly one level of undo, deliberately: the affordance exists because
527    /// repositioning is otherwise irreversible (the previous layout is simply
528    /// gone), and "undo the thing I just did" is the whole of what that needs. A
529    /// deeper stack would raise questions — what does undoing an older batch mean
530    /// once a newer one has moved the same window? — that a one-level store cannot
531    /// pose. Each successful `reposition` replaces it; each `reposition-undo`
532    /// consumes it, so an undo cannot be replayed.
533    ///
534    /// In-memory only, like every other piece of registry state: a daemon restart
535    /// drops it, and the user is simply left with the layout they have. Behind its
536    /// **own** `std::Mutex`, taken independently of the registry's (neither nests)
537    /// and never held across an `.await`.
538    reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
539    /// Serializes the `rebase` op's phase-2 execute across concurrent requests
540    /// (#1415) — the [`prune_lock`](Self::prune_lock) precedent, one op over.
541    ///
542    /// Within a batch the engine already rebases sequentially, on purpose: linked
543    /// worktrees share one object database, and `git rebase` writes refs and
544    /// packs into it. Two *concurrent requests* would defeat that, so the lock
545    /// restores it globally. It also means a second click cannot start a rebase of
546    /// a worktree the first is still mid-way through — the `operation`-in-progress
547    /// classifier only sees state that is already on disk.
548    ///
549    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
550    rebase_lock: tokio::sync::Mutex<()>,
551}
552
553impl WorktreesService {
554    /// Creates the service with an empty registry. Cheap — no I/O and no task;
555    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
556    /// off-thread menu caching, while tests use the bare service (menu computed
557    /// inline on demand).
558    #[must_use]
559    pub fn new() -> Self {
560        let registry = Arc::new(WorktreesRegistry::new());
561        let pr_cache = Arc::new(PrStatusCache::new());
562        Self {
563            registry: registry.clone(),
564            menu_cache: Arc::new(Mutex::new(None)),
565            refresh: Mutex::new(None),
566            pr_cache: pr_cache.clone(),
567            poller: Mutex::new(None),
568            rate_limit_cache: Arc::new(RateLimitCache::new()),
569            rate_limit_poller: Mutex::new(None),
570            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
571            prune_lock: tokio::sync::Mutex::new(()),
572            polling_prefs_path: Mutex::new(None),
573            pr_cache_path: Mutex::new(None),
574            pr_warm_start: Mutex::new(None),
575            open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
576            reposition_undo: Mutex::new(Vec::new()),
577            rebase_lock: tokio::sync::Mutex::new(()),
578        }
579    }
580
581    /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
582    /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
583    /// back to it (#1376). Called once by the daemon at startup, before any window
584    /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
585    /// bump. Best-effort throughout: a missing file is the first-run default (no
586    /// repos enabled), and a corrupt/unreadable one is logged and treated as
587    /// empty rather than wedging the service — the user simply re-enables. The
588    /// path is stored regardless, so the next change rewrites a clean file.
589    pub fn load_polling_prefs(&self, path: PathBuf) {
590        match std::fs::read(&path) {
591            Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
592                Ok(prefs) => self
593                    .registry
594                    .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
595                Err(err) => tracing::warn!(
596                    "ignoring unreadable worktrees polling prefs at {}: {err:#}",
597                    path.display()
598                ),
599            },
600            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
601            Err(err) => tracing::warn!(
602                "could not read worktrees polling prefs at {}: {err:#}",
603                path.display()
604            ),
605        }
606        *self
607            .polling_prefs_path
608            .lock()
609            .unwrap_or_else(PoisonError::into_inner) = Some(path);
610    }
611
612    /// Writes the current enable set to the `0600` prefs file, if persistence is
613    /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
614    /// Best-effort: a write failure is logged at WARN and swallowed, since the
615    /// in-memory set is authoritative for the running daemon — the user's toggle
616    /// still took effect, it just would not survive a restart. A no-op (returns
617    /// early) in unit tests, which never configure a path.
618    fn persist_polling_prefs(&self) {
619        let Some(path) = self
620            .polling_prefs_path
621            .lock()
622            .unwrap_or_else(PoisonError::into_inner)
623            .clone()
624        else {
625            return;
626        };
627        let prefs = PollingPrefs {
628            enabled: self
629                .registry
630                .polling_snapshot()
631                .into_iter()
632                .map(|(repo, expires_at)| PollingLease { repo, expires_at })
633                .collect(),
634        };
635        if let Err(err) = write_polling_prefs(&path, &prefs) {
636            tracing::warn!(
637                "could not persist worktrees polling prefs to {}: {err:#}",
638                path.display()
639            );
640        }
641    }
642
643    /// Seeds the resolved PR-badge cache from the persisted `0600` file and
644    /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
645    /// once by the daemon at startup, before any window subscribes and before the
646    /// poller spawns, so restored badges render on the first tree snapshot and the
647    /// poller can skip its immediate re-poll for verdicts still fresh.
648    ///
649    /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
650    /// contract): a missing file is the cold-start default, and a corrupt/unreadable
651    /// one is logged and treated as empty — the poller simply re-resolves. The path
652    /// is stored regardless, so the next poll rewrites a clean file. Restores both
653    /// the badges (into [`pr_cache`](Self::pr_cache)) and the
654    /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
655    pub fn load_pr_cache(&self, path: PathBuf) {
656        match std::fs::read(&path) {
657            Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
658                Ok(prefs) => {
659                    self.pr_cache.seed(
660                        prefs
661                            .entries
662                            .into_iter()
663                            .map(|e| (e.target, e.resolution.into_resolution())),
664                    );
665                    // A warm start needs both a watch set to compare against and a
666                    // poll time to age it; without `polled_at` the file is too old a
667                    // shape to trust, so treat it as a cold start (badges still
668                    // render, the poller just re-polls immediately).
669                    if let Some(polled_at) = prefs.polled_at {
670                        let watched = prefs
671                            .watched
672                            .into_iter()
673                            .map(|w| PrWatch {
674                                target: w.target,
675                                upstream_sha: w.upstream_sha,
676                            })
677                            .collect();
678                        *self
679                            .pr_warm_start
680                            .lock()
681                            .unwrap_or_else(PoisonError::into_inner) =
682                            Some(PrWarmStart { watched, polled_at });
683                    }
684                }
685                // Bind the path so it is formatted whenever the branch runs — not
686                // only when a WARN subscriber is installed — so coverage sees it
687                // (the `let summary = …` pattern the rate-limit warn uses).
688                Err(err) => {
689                    let at = path.display();
690                    tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
691                }
692            },
693            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
694            Err(err) => {
695                let at = path.display();
696                tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
697            }
698        }
699        *self
700            .pr_cache_path
701            .lock()
702            .unwrap_or_else(PoisonError::into_inner) = Some(path);
703    }
704
705    /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
706    /// served from the shared TTL cache when fresh, else **one** counted `gh pr
707    /// list`. The `gh` runs on a blocking thread (never an async worker), routed
708    /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
709    /// point so the call is still counted exactly once — the constraint the whole
710    /// of #1389 preserves. The result is forwarded to the extension verbatim.
711    async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
712        // The `gh` binary is resolved once here (the env read is process-stable),
713        // then handed to the seam below — the poller's "bin as a param" pattern, so
714        // a test injects a stub without mutating the process environment (#1030).
715        self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
716            .await
717    }
718
719    /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
720    /// the cache against a stub without touching the environment.
721    async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
722        let key = format!("{owner}/{name}");
723        if let Some(prs) = self.open_pr_cache.fresh(&key) {
724            return Ok(prs);
725        }
726        let slug = key.clone();
727        let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
728            .await
729            .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
730        self.open_pr_cache.store(key, prs.clone());
731        Ok(prs)
732    }
733
734    /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
735    /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
736    /// for the built-in `status` op to read.
737    #[must_use]
738    pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
739        self.rate_limit_cache.clone()
740    }
741
742    /// Starts the background task that recomputes the tray menu snapshot every
743    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
744    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
745    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
746    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
747    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
748    /// keep computing the menu inline.
749    pub fn start_menu_refresh(&self) {
750        if tokio::runtime::Handle::try_current().is_err() {
751            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
752            return;
753        }
754        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
755        if guard.is_some() {
756            return;
757        }
758        let token = CancellationToken::new();
759        let loop_token = token.clone();
760        let registry = self.registry.clone();
761        let cache = self.menu_cache.clone();
762        let rate_limit_cache = self.rate_limit_cache.clone();
763        // Resolved once at spawn: the interval is process-stable env config, and
764        // re-reading it every loop would be wasted work.
765        let interval = menu_refresh_interval();
766        let handle = tokio::spawn(async move {
767            loop {
768                // Snapshot the registry (a cheap lock), then build the menu —
769                // which opens repos and parses git config — on a blocking thread,
770                // never on this async worker or the tray's main thread.
771                let entries = registry.list();
772                // The rate-limit reading (a cheap lock, `Copy`) prepends a status
773                // line; read it here and hand it to the blocking build (#1375).
774                let rate_limit = rate_limit_cache.get();
775                if let Ok(items) = tokio::task::spawn_blocking(move || {
776                    menu_items_for(&entries, rate_limit.as_ref())
777                })
778                .await
779                {
780                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
781                }
782                tokio::select! {
783                    () = loop_token.cancelled() => break,
784                    () = tokio::time::sleep(interval) => {}
785                }
786            }
787        });
788        *guard = Some(RefreshTask { token, handle });
789    }
790
791    /// Starts the background task that keeps PR check badges fresh (#1337).
792    ///
793    /// This is the half of the badge nothing else can do. Badges used to be
794    /// resolved extension-side on repo-expand, so they were recomputed only when a
795    /// repo node's children were rebuilt — and the streamed snapshot carries
796    /// worktree topology, not CI. While CI ran and no window opened or closed,
797    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
798    ///
799    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
800    /// (cost 1, independent of repo/worktree/window count), writes the cache the
801    /// tree snapshot reads, and bumps the registry's change-notify **only when a
802    /// verdict actually moved** — so the server's diff pushes to every open window
803    /// exactly when CI state changes, and never otherwise.
804    ///
805    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
806    /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
807    /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
808    /// terminal; it polls nothing at all while no window is registered.
809    ///
810    /// It spends a `gh` call only when the watch set **grows** — a target added or
811    /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
812    /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
813    /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
814    /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
815    /// 4), and the cadence is capped when the shared GitHub budget is strained
816    /// (#1389 fix 6).
817    ///
818    /// Idempotent, and a no-op outside a tokio runtime (mirroring
819    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
820    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
821    pub fn start_pr_poller(&self) {
822        // Resolved once at spawn: process-stable env config (the menu-refresh
823        // precedent), never re-read per poll.
824        self.start_pr_poller_with(
825            pr_poll_interval(),
826            pr_debounce_interval(),
827            crate::pr_status::resolve_gh_binary(),
828        );
829    }
830
831    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
832    /// debounce settle window, and `gh` binary, so tests drive the loop at
833    /// millisecond speed against a stub **without mutating the process
834    /// environment** — one global env var cannot serve two parallel tests pointing
835    /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
836    /// Snowflake heartbeat's "interval via config, not env" rule.
837    ///
838    /// Reads the rate-limit cache, the persistence path, and the warm-start state
839    /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
840    /// the original two-cadence seam.
841    fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
842        if tokio::runtime::Handle::try_current().is_err() {
843            tracing::debug!("no tokio runtime; worktrees PR poller not started");
844            return;
845        }
846        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
847        if guard.is_some() {
848            return;
849        }
850        let token = CancellationToken::new();
851        let loop_token = token.clone();
852        let registry = self.registry.clone();
853        let tree_cache = self.tree_cache.clone();
854        let pr_cache = self.pr_cache.clone();
855        // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
856        // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
857        // the loop once and must not be reused by a later restart of the poller.
858        let rate_limit_cache = self.rate_limit_cache.clone();
859        let pr_cache_path = self
860            .pr_cache_path
861            .lock()
862            .unwrap_or_else(PoisonError::into_inner)
863            .clone();
864        let warm_start = self
865            .pr_warm_start
866            .lock()
867            .unwrap_or_else(PoisonError::into_inner)
868            .take();
869        // Captured here, before the task's first sleep, so a window that registers
870        // while the loop is starting still wakes it rather than being missed.
871        let mut changes = self.registry.subscribe_changes();
872        let handle = tokio::spawn(async move {
873            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
874            // of the coalescing snapshot cache, no subprocess, no network. It only
875            // *asks GitHub* when there is reason to: the watch set grew (a target
876            // added, or an upstream pushed), or the backoff has elapsed.
877            //
878            // They have to be separate because the two things that should trigger a
879            // fetch arrive by different routes. A window opening bumps the registry's
880            // change-notify, but **a push does not** — nothing in the daemon is
881            // notified when you `git push`. The only way to notice is to look, so the
882            // loop looks often and cheaply, and pays only when something grew.
883            let mut backoff = base;
884            // Warm start (#1389, fix 4): resume what the previous daemon last
885            // resolved and when, so a restart within the backoff window skips the
886            // immediate re-poll for verdicts already restored into `pr_cache`.
887            // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
888            // reboot (monotonic epoch reset) or a future timestamp collapses to
889            // "never polled", which just re-polls — the safe direction.
890            let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
891                match warm_start {
892                    Some(ws) => {
893                        let elapsed = (Utc::now() - ws.polled_at)
894                            .to_std()
895                            .unwrap_or(Duration::ZERO);
896                        (Some(ws.watched), Instant::now().checked_sub(elapsed))
897                    }
898                    None => (None, None),
899                };
900            // When fresh work (a push or an added target) was last seen, so the
901            // pending cadence can escalate once it goes quiet (#1389, fix 5).
902            let mut moved_at: Option<Instant> = None;
903            'poll: loop {
904                // Wait first: at startup no window has registered yet, and the
905                // first snapshot would be empty anyway.
906                tokio::select! {
907                    () = loop_token.cancelled() => break,
908                    () = tokio::time::sleep(base) => {}
909                    // A window opened or closed — look now rather than at the next
910                    // tick, but debounce first.
911                    result = changes.changed() => {
912                        // Unreachable today: this task owns an `Arc` of the registry
913                        // that holds the sender, so it cannot be dropped while we are
914                        // here. Kept anyway because the alternative is worse — a
915                        // closed channel makes `changed()` return `Ready` forever, so
916                        // ignoring the error would spin this loop at full speed,
917                        // re-snapshotting and re-running `gh` every iteration.
918                        if result.is_err() {
919                            break;
920                        }
921                        // Debounce (#1389, fix 2): a VS Code restart unregisters then
922                        // re-registers its windows one-by-one over several seconds,
923                        // each bump waking us; a daemon restart re-registers the same
924                        // way. Wait for `debounce` of quiet before snapshotting so the
925                        // whole storm collapses to **one** fetch on the final watch
926                        // set. Bounded by an overall deadline so a steady drip of
927                        // changes cannot postpone the poll forever.
928                        let overall_deadline = Instant::now() + debounce.saturating_mul(4);
929                        loop {
930                            tokio::select! {
931                                () = loop_token.cancelled() => break 'poll,
932                                () = tokio::time::sleep(debounce) => break,
933                                r = changes.changed() => {
934                                    if r.is_err() {
935                                        break 'poll;
936                                    }
937                                    if Instant::now() >= overall_deadline {
938                                        break;
939                                    }
940                                }
941                            }
942                        }
943                    }
944                }
945                // Off the coalescing snapshot cache, so this reuses the tick's
946                // `build_tree` rather than walking git a second time.
947                let snapshot = tree_cache.snapshot().await;
948                let watch = pr_watch_from_snapshot(&snapshot);
949                if watch.is_empty() {
950                    // No windows, or nothing on GitHub: ask nothing, and forget any
951                    // backoff so the next tree starts fresh. But **keep** `watched`
952                    // (#1389, fix 4): a VS Code restart momentarily empties the watch
953                    // mid-storm, and nulling it here would make the re-registered set
954                    // look brand-new and re-fetch. A genuinely gone tree simply has
955                    // nothing to compare against on the next non-empty tick.
956                    backoff = base;
957                    last_poll = None;
958                    moved_at = None;
959                    continue;
960                }
961                // Did the watched set **grow** (an addition, or an upstream pushed)?
962                // A pure removal is not a reason to fetch (#1389, fix 1); a local
963                // commit is not either (#1389, fix 3, `PrWatch` carries no head).
964                let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
965                // Prune verdicts for targets that vanished, so a closed worktree's
966                // badge does not linger in the cache (#1389, fix 1) — local, no
967                // network, and correct whether or not this tick goes on to fetch.
968                let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
969                pr_cache.retain_targets(&keep);
970                // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
971                // choke point, so throttling here is the one place a machine-wide cap
972                // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
973                // immediate `grew`, so no runaway in this class can drain the shared
974                // budget — structurally, not by convention.
975                let rate_limit = rate_limit_cache.get();
976                let over_budget = rate_limit.is_some_and(|s| s.over_warn());
977                let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
978                let trigger = grew && !over_budget;
979                if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
980                    // Not fetching this tick. Advance `watched` only for a pure shrink
981                    // or a quiet identical tick — an addition/push (`grew`) must stay
982                    // unresolved so it is still fetched once the cadence or budget
983                    // allows, rather than being silently consumed here.
984                    if !grew {
985                        watched = Some(watch);
986                    }
987                    continue;
988                }
989                if grew {
990                    // Fresh work: watch it closely and restart the escalation clock
991                    // rather than serving out a backoff earned while it was quiet.
992                    backoff = base;
993                    moved_at = Some(Instant::now());
994                }
995                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
996                // `gh` is a blocking subprocess: never on an async worker. A join
997                // failure (the task panicked, or the runtime is going down) folds
998                // into the same error channel as a `gh` failure — both mean "no
999                // badges this round", and neither deserves its own handling.
1000                let bin = gh_bin.clone();
1001                let resolved = tokio::task::spawn_blocking(move || {
1002                    crate::pr_status::resolve_with_budget(&bin, &targets)
1003                })
1004                .await
1005                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1006                // Best-effort decoration: a missing/unauthenticated `gh`, a network
1007                // blip, or a rate limit must never sink the tree. A failed poll
1008                // leaves the last good resolutions in place — badges *and* explicit
1009                // negatives, and it mints no new negatives (#1370) — rather than
1010                // blanking every row, and is not "pending", so it backs off rather
1011                // than hammers.
1012                let (pending, resolved_ok) = match resolved {
1013                    Ok((resolutions, budget)) => {
1014                        // Fold the free budget reading this poll carried into the
1015                        // shared cache (#1389, fix 8): the graphql figure stays fresh
1016                        // whenever polling is active, which lets the standalone
1017                        // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
1018                        // reveals the real per-call point price.
1019                        if let Some(b) = budget {
1020                            tracing::debug!(
1021                                "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1022                                b.cost,
1023                                b.used,
1024                                b.limit,
1025                                b.remaining
1026                            );
1027                            rate_limit_cache.observe_graphql(RateLimitResource::new(
1028                                b.used,
1029                                b.limit,
1030                                b.remaining,
1031                                b.reset,
1032                            ));
1033                        }
1034                        // Bump only on a real change, or the server's diff-and-drop
1035                        // is defeated and every window re-renders on every poll.
1036                        if pr_cache.replace(resolutions) {
1037                            registry.bump();
1038                        }
1039                        (pr_cache.any_pending(), true)
1040                    }
1041                    Err(err) => {
1042                        tracing::debug!("PR badge poll failed: {err:#}");
1043                        (false, false)
1044                    }
1045                };
1046                last_poll = Some(Instant::now());
1047                // Record what this verdict was about, so the *next* tick can tell a
1048                // genuine change from a quiet tree.
1049                watched = Some(watch);
1050                let since_moved = moved_at.map(|at| at.elapsed());
1051                backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1052                // Persist the fresh verdicts (#1389, fix 4) so the next restart
1053                // serves them and can skip its immediate re-poll. Only on a real
1054                // resolution — a failed poll must not advance the persisted poll time
1055                // past the last *good* one. Best-effort; a write failure costs at
1056                // most one extra poll after the next restart.
1057                if resolved_ok {
1058                    if let Some(path) = &pr_cache_path {
1059                        persist_pr_cache(
1060                            path,
1061                            &pr_cache,
1062                            watched.as_deref().unwrap_or(&[]),
1063                            Utc::now(),
1064                        );
1065                    }
1066                }
1067            }
1068        });
1069        *guard = Some(PollerTask { token, handle });
1070    }
1071
1072    /// Starts the background task that keeps the GitHub API rate-limit reading
1073    /// fresh (#1375).
1074    ///
1075    /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1076    /// budget as every other tool sharing the user's token; when that drains, `gh`
1077    /// rate-limits machine-wide with no warning until commands start failing. This
1078    /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1079    /// project verified) as **exempt**, spending nothing against any budget — so
1080    /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1081    /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1082    ///
1083    /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1084    /// backoff and no window-gating, because the endpoint is free and a current
1085    /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1086    /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1087    /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1088    /// bare service that never spawns `gh`.
1089    pub fn start_rate_limit_poller(&self) {
1090        // Both resolved once at spawn: process-stable env config, never re-read per
1091        // poll (the PR-poller precedent).
1092        self.start_rate_limit_poller_with(
1093            rate_limit_poll_interval(),
1094            crate::pr_status::resolve_gh_binary(),
1095        );
1096    }
1097
1098    /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1099    /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1100    /// against a stub **without mutating the process environment** (the
1101    /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1102    fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1103        if tokio::runtime::Handle::try_current().is_err() {
1104            tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1105            return;
1106        }
1107        let mut guard = self
1108            .rate_limit_poller
1109            .lock()
1110            .unwrap_or_else(PoisonError::into_inner);
1111        if guard.is_some() {
1112            return;
1113        }
1114        let token = CancellationToken::new();
1115        let loop_token = token.clone();
1116        let cache = self.rate_limit_cache.clone();
1117        let registry = self.registry.clone();
1118        let handle = tokio::spawn(async move {
1119            // Remembers the previous reading so a WARN fires only on the *rising*
1120            // edge across the threshold, not every poll while usage stays high.
1121            let mut prev: Option<RateLimitSnapshot> = None;
1122            loop {
1123                // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1124                // no window registered and no polling lease active — has nothing to
1125                // watch, so it spends no `/rate_limit` subprocess and lets the last
1126                // reading stand. The read is free against the budget, but the
1127                // wakeups are not; and while polling *is* active the graphql figure
1128                // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1129                // standalone poll is only topping up `core`/`search`.
1130                if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1131                    // Poll first, so `status` has a reading soon after a window
1132                    // appears rather than one interval later. `gh` is a blocking
1133                    // subprocess: never on an async worker. A join failure folds into
1134                    // the same channel as a `gh` failure — both mean "no fresh
1135                    // reading this round".
1136                    let bin = gh_bin.clone();
1137                    let resolved =
1138                        tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1139                            .await
1140                            .unwrap_or_else(|err| {
1141                                Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1142                            });
1143                    match resolved {
1144                        Ok(snap) => {
1145                            if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1146                                // Bound to a local (rather than inlined into the
1147                                // macro) so it is computed whenever the branch is
1148                                // taken, not only when a WARN-level subscriber is
1149                                // installed — `tracing` skips evaluating macro args
1150                                // otherwise.
1151                                let summary = snap.summary_line();
1152                                tracing::warn!(
1153                                    "GitHub API rate limit high: {summary} (querying \
1154                                     /rate_limit is free; the daemon's gh usage is not)"
1155                                );
1156                            }
1157                            // Update the cache only; deliberately no `registry.bump()`
1158                            // — the rate limit is not tree topology, and bumping would
1159                            // re-push an unchanged tree to every window. The tray
1160                            // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1161                            cache.replace(snap);
1162                            prev = Some(snap);
1163                        }
1164                        // Best-effort decoration: a missing/unauthenticated `gh` or a
1165                        // network blip leaves the last good reading in place rather
1166                        // than blanking the line, and never affects the budget (the
1167                        // read is free).
1168                        Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1169                    }
1170                }
1171                tokio::select! {
1172                    () = loop_token.cancelled() => break,
1173                    () = tokio::time::sleep(interval) => {}
1174                }
1175            }
1176        });
1177        *guard = Some(PollerTask { token, handle });
1178    }
1179
1180    /// Handles the `close` op: close a worktree's window and, for a **linked**
1181    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1182    ///
1183    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1184    ///   [`git_safety`] check returning the risks of deleting, so the extension can
1185    ///   show a modal confirm only when something would actually be lost.
1186    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1187    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1188    ///   worktree. The main working tree is refused defensively.
1189    ///
1190    /// Cross-window signalling (another window has the target open) is a
1191    /// fast-follow: this core handles the **no-window** and **self-close**
1192    /// (`requester_key == target_key`) cases, and errors clearly when another
1193    /// window owns the target so the destructive path is never taken blind.
1194    async fn close(&self, req: CloseRequest) -> Result<Value> {
1195        // Which live windows currently have the target open. The canonical-path
1196        // compare is disk I/O, so run it (with the safety check below) on a
1197        // blocking thread, never under the registry lock or on the async worker.
1198        let entries = self.registry.list();
1199        let scan_path = req.path.clone();
1200        let open_windows =
1201            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1202                .await
1203                .unwrap_or_default();
1204        let open = !open_windows.is_empty();
1205        let window_key = open_windows.first().map(|(k, _)| k.clone());
1206        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1207
1208        // Phase 1: the safety check runs only for a delete request awaiting
1209        // confirmation. A "Close Window" (remove:false) never inspects git and
1210        // has nothing to confirm, so it skips straight to execute.
1211        if req.remove && !req.confirmed {
1212            let path = req.path.clone();
1213            let git = tokio::task::spawn_blocking(move || git_safety(&path))
1214                .await
1215                .map_err(|e| anyhow!("safety check task panicked: {e}"))
1216                .and_then(|inner| inner)
1217                .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1218            // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1219            // (#1364): the target, the owning window key (if any), whether a
1220            // window has it open, and the deletability verdict — with the
1221            // blocking risk kinds that force a confirm dialog, so a later "why did
1222            // the close prompt/refuse?" is answerable from the log alone.
1223            log_safety_check(&req.path, window_key.as_deref(), &git, open);
1224            return Ok(serde_json::to_value(SafetyReport {
1225                removable: git.removable,
1226                is_main: git.is_main,
1227                open,
1228                window_key,
1229                window_folder_count,
1230                risks: git.risks,
1231                info: git.info,
1232            })
1233            .unwrap_or_else(|_| json!({})));
1234        }
1235
1236        // Phase 2: execute. Signal every owning window *other than the
1237        // requester* (which closes itself on our `ok:true` reply, avoiding the
1238        // ext-host-dies-mid-op race) and wait for each to unregister before
1239        // touching the worktree. The directive reaches a cross-window target via
1240        // its heartbeat reply — the only channel the daemon has to a window it
1241        // can reply to but never call.
1242        let others: Vec<String> = open_windows
1243            .iter()
1244            .map(|(k, _)| k.clone())
1245            .filter(|k| req.requester_key.as_deref() != Some(k))
1246            .collect();
1247        // A self-close is the requester closing a window it owns: it never rides
1248        // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1249        // (#1364) so the execute's routing decision is auditable before the wait,
1250        // even if that wait then hangs or times out.
1251        let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1252        log_executing(
1253            &req.path,
1254            req.requester_key.as_deref(),
1255            req.remove,
1256            self_close,
1257            others.len(),
1258        );
1259        for key in &others {
1260            self.registry.mark_close_pending(key);
1261        }
1262        if !others.is_empty() {
1263            if let Err(err) = await_windows_closed(
1264                &self.registry,
1265                &req.path,
1266                req.requester_key.as_deref(),
1267                CLOSE_WAIT_TIMEOUT,
1268                CLOSE_WAIT_POLL,
1269            )
1270            .await
1271            {
1272                log_close_abort(&req.path, &err);
1273                return Err(err);
1274            }
1275        }
1276
1277        if req.remove {
1278            let path = req.path.clone();
1279            // The live window set, so a working-tree-gone-but-admin-present orphan
1280            // can find its owning main repo to prune (#1403); unused on the common
1281            // path where the checkout still exists.
1282            let entries = self.registry.list();
1283            // Taken *after* the wait above, so concurrent executes still overlap
1284            // their heartbeat waits (#1359) and only the prune itself serializes.
1285            // Load-bearing placement, not incidental: hoisting this above
1286            // `await_windows_closed` would restack the waits and undo the whole
1287            // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1288            let _guard = self.prune_lock.lock().await;
1289            let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1290                .await
1291                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1292                .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1293            // The audit line + Result→reply mapping lives in a sync helper so the
1294            // destructive outcome is unit-testable off the runtime (#1364).
1295            log_and_map_removal(&req.path, removed)
1296        } else {
1297            // "Close Window" with no owning window is a no-op success; a
1298            // self-close replies and the extension closes its own window.
1299            log_window_closed(&req.path);
1300            Ok(json!({ "closed": true }))
1301        }
1302    }
1303
1304    /// Handles the `reload` op (#1417): signal each target window to reload
1305    /// itself, returning `{ requested, signalled, unknown }`.
1306    ///
1307    /// Synchronous, and deliberately so — the whole op is a set insert per key.
1308    /// It marks a directive on each *currently registered* target and returns;
1309    /// the window acts on it on its next `heartbeat`, up to the ~10s cadence
1310    /// later. Unlike [`close`](Self::close) it never waits, because a reload has
1311    /// no completion the daemon can observe (the window re-registers under the
1312    /// same key), which is why the reply says `signalled`, never `reloaded`.
1313    ///
1314    /// A key with no live window is reported in `unknown` rather than erroring:
1315    /// the batch is a sweep, and a window closing between the client rendering
1316    /// its list and sending the op is routine, not a failure. `list()` reaps
1317    /// stale entries on read, so a window that died without unregistering is
1318    /// correctly unknown here.
1319    fn reload(&self, req: ReloadRequest) -> Value {
1320        let live: HashSet<String> = self
1321            .registry
1322            .list()
1323            .into_iter()
1324            .map(|entry| entry.key)
1325            .collect();
1326
1327        let mut seen = HashSet::new();
1328        let mut signalled = 0usize;
1329        let mut unknown = Vec::new();
1330        for key in &req.target_keys {
1331            // A client repeating a key asks for one reload, not two.
1332            if !seen.insert(key.as_str()) {
1333                continue;
1334            }
1335            if live.contains(key) {
1336                self.registry.mark_reload_pending(key);
1337                signalled += 1;
1338            } else {
1339                unknown.push(key.clone());
1340            }
1341        }
1342
1343        log_reload(seen.len(), signalled, &unknown);
1344        json!({
1345            "requested": seen.len(),
1346            "signalled": signalled,
1347            "unknown": unknown,
1348        })
1349    }
1350
1351    /// Handles the `merge-queue` op (#1401): batch-enqueue the eligible worktrees'
1352    /// PRs into the GitHub merge queue. Two-phase, keyed off `confirmed`:
1353    ///
1354    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run the
1355    ///   side-effect-free eligibility evaluation ([`evaluate_batch`]) and return an
1356    ///   [`EligibilityReport`]: the enqueue-eligible worktrees and the skipped ones
1357    ///   (each with a machine `kind` + human `detail`).
1358    /// - **Phase 2** (`confirmed:true`) — **re-run** the same evaluation (never
1359    ///   trust a phase-1 result the client sent, exactly as `close` re-validates on
1360    ///   execute), then enqueue each still-eligible PR. A per-PR rejection lands in
1361    ///   `failed[]`; the batch never fails as a whole.
1362    ///
1363    /// All git and `gh` I/O runs on a blocking thread — never the async worker and
1364    /// never under the registry lock. No lock is taken: each enqueue mutates a
1365    /// distinct remote PR, not a shared local resource.
1366    async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1367        // Resolved once here (the env read is process-stable), then handed to the
1368        // seam below — the `open_prs`/`open_prs_with` "bin as a param" pattern, so a
1369        // test drives the eligibility + enqueue paths against a fake `gh` without
1370        // touching the environment (#1030).
1371        self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1372            .await
1373    }
1374
1375    /// [`merge_queue`](Self::merge_queue) with an explicit `gh` binary, so a test
1376    /// exercises the phase-1 network resolve and the phase-2 enqueue against a stub.
1377    async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1378        // Report-only unless explicitly confirmed; an explicit `check` request
1379        // always reports and never enqueues.
1380        let report_only = req.check || !req.confirmed;
1381
1382        let eval_bin = bin.clone();
1383        let eval_paths = req.paths.clone();
1384        let (eligible, skipped) =
1385            tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1386                .await
1387                .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1388                .and_then(|inner| inner)?;
1389
1390        if report_only {
1391            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1392            log_merge_check(&req, eligible.len(), skipped.len());
1393            let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1394            return Ok(
1395                serde_json::to_value(EligibilityReport { eligible, skipped })
1396                    .unwrap_or_else(|_| json!({})),
1397            );
1398        }
1399
1400        // Phase 2: enqueue the freshly re-validated eligible set, sequentially.
1401        let enqueue_bin = bin.clone();
1402        let (queued, failed) =
1403            tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1404                .await
1405                .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1406        log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1407        Ok(serde_json::to_value(EnqueueResult {
1408            queued,
1409            skipped,
1410            failed,
1411        })
1412        .unwrap_or_else(|_| json!({})))
1413    }
1414
1415    /// Handles the `rebase` op (#1415): batch-rebase the selected worktrees onto
1416    /// their repository's remote default branch, fetching it **once per
1417    /// repository**. Two-phase, keyed off `confirmed`, exactly like `merge-queue`:
1418    ///
1419    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1420    ///   [`worktree_rebase::plan`], which fetches once per repo and classifies
1421    ///   every selected worktree. This *is* the "only rebase if it makes sense
1422    ///   from the current git state" gate: the classifier skips the main working
1423    ///   tree, a detached HEAD, a dirty tree, an operation already in progress, a
1424    ///   non-worktree path, an unresolvable onto ref, and anything already up to
1425    ///   date. Side-effect-free apart from the fetch, which only advances a
1426    ///   remote-tracking ref.
1427    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch** (never trust a
1428    ///   phase-1 result the client sent back, as `close` and `merge-queue` do),
1429    ///   then execute. A worktree that went dirty between the phases is skipped
1430    ///   rather than rebased.
1431    ///
1432    /// **Why the daemon may do this at all** (ADR-0059): ADR-0055 confined the
1433    /// rebase to the CLI on the premise that the daemon could not authenticate a
1434    /// fetch. It can — launchd exports `SSH_AUTH_SOCK` into the per-user session,
1435    /// so the daemon inherits the user's `ssh-agent`. The real gap was the minimal
1436    /// `PATH`, closed by [`crate::git::resolve_git_binary`].
1437    ///
1438    /// All git I/O runs on a blocking thread, never the async worker and never
1439    /// under the registry lock.
1440    async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1441        // Resolved once here (the probe is process-stable), then handed to the
1442        // seam below — the `merge_queue_with` "bin as a param" pattern, so a test
1443        // drives both phases against a stub without touching the environment.
1444        self.rebase_with(req, crate::git::resolve_git_binary())
1445            .await
1446    }
1447
1448    /// [`rebase`](Self::rebase) with an explicit `git` binary, so a test exercises
1449    /// the plan and execute paths against a stub.
1450    async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1451        if req.paths.is_empty() {
1452            bail!("`rebase` requires at least one path");
1453        }
1454        // Report-only unless explicitly confirmed; an explicit `check` request
1455        // always reports and never rebases.
1456        let report_only = req.check || !req.confirmed;
1457        let opts = req.options(git_bin);
1458        let selection = Selection::Paths(req.paths.clone());
1459
1460        if report_only {
1461            // Phase 1: fetch once per repo and classify, rebase nothing. No lock —
1462            // it mutates no worktree, and a plan is allowed to race an execute.
1463            let plan = plan_rebase(&selection, &opts).await?;
1464            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1465            log_rebase_check(&req, &plan);
1466            return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1467        }
1468
1469        // Phase 2: re-plan and execute, serialized against other executes —
1470        // linked worktrees share one object database (see `rebase_lock`).
1471        //
1472        // The lock is taken **before** the re-plan, not merely around the execute,
1473        // and that ordering is load-bearing. A plan taken outside it can be
1474        // invalidated by a concurrent execute before this one gets its turn — and
1475        // acting on a stale plan is not benign: if the other run left a worktree
1476        // mid-rebase, a stale `WouldRebase` here would run `git rebase` against a
1477        // repository that is already mid-rebase and (without `keep_conflicts`)
1478        // `--abort` it, destroying exactly the conflict resolution the other run
1479        // was preserving. Planning under the lock means the classifier sees that
1480        // worktree's real state and skips it as `operation-in-progress`.
1481        let _guard = self.rebase_lock.lock().await;
1482        let plan = plan_rebase(&selection, &opts).await?;
1483        // The worktrees actually about to be rewritten, canonicalized here (disk
1484        // I/O belongs in the adapter, not the registry) so they match the tree
1485        // snapshot's own keys.
1486        let pending: Vec<PathBuf> = plan
1487            .worktrees
1488            .iter()
1489            .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1490            .map(|w| canonical(&w.path))
1491            .collect();
1492        self.registry.mark_rebasing(&pending);
1493        let fetches = plan.fetches.clone();
1494        let exec_opts = opts.clone();
1495        let outcomes =
1496            tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1497        // Cleared on **every** exit, including a panicked task, so a failed rebase
1498        // can never leave a permanent spinner on a tree row.
1499        self.registry.clear_rebasing(&pending);
1500        let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1501
1502        log_rebase_execute(&req, &outcomes);
1503        Ok(rebase_reply(&fetches, &outcomes))
1504    }
1505
1506    /// Handles the `reposition` op (#1407): move and resize each target worktree's
1507    /// **already-open** VS Code window to match the invoking window's geometry.
1508    ///
1509    /// The invoking window is the reference: it supplies the frame and is never
1510    /// itself moved. A target with no open window, no resolvable OS window, or an
1511    /// ambiguous name is reported rather than guessed at. Z-order is untouched —
1512    /// see [`geometry::ax`] for why the Accessibility API gives that for free, and
1513    /// why this must **not** reuse [`focus_window`], which deliberately raises.
1514    ///
1515    /// `check: true` is a dry run: everything resolves exactly as it would for a
1516    /// real run, but nothing is written. That is the whole diagnostic surface for
1517    /// title matching (`worktrees reposition --dry-run`).
1518    ///
1519    /// Not two-phase like `close`/`merge-queue`: nothing durable is created,
1520    /// modified, or destroyed, so a confirmation on a routine layout command would
1521    /// cost more than it protects. Reversibility is provided instead, by
1522    /// [`reposition_undo`](Self::reposition_undo).
1523    async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1524        self.reposition_with(req, geometry::ax::AxBackend::new)
1525            .await
1526    }
1527
1528    /// [`reposition`](Self::reposition) with the platform backend injected as a
1529    /// **factory**, so a test drives the whole op — key resolution, the undo
1530    /// store, the reply shape — against a fake with no `unsafe` and no windows.
1531    ///
1532    /// A factory rather than the backend itself because the real backend holds
1533    /// CoreFoundation references and so is neither `Send` nor `Sync`: it has to be
1534    /// built *inside* the blocking closure. The `merge_queue_with` "seam as a
1535    /// parameter" pattern, one level of indirection over.
1536    async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1537    where
1538        B: geometry::WindowBackend,
1539        F: FnOnce() -> B + Send + 'static,
1540    {
1541        if req.reference_key.trim().is_empty() {
1542            bail!("`reposition` requires a non-empty `reference_key`");
1543        }
1544        // Resolve keys against the registry *here*, so all AX work below deals in
1545        // plain data and the registry lock is never held across the blocking join.
1546        let entries = self.registry.list();
1547        let reference = registered_window(&entries, &req.reference_key);
1548        if !reference.live {
1549            // Unlike a target, an unresolvable *reference* is a hard error: there
1550            // is no geometry to copy, so the request cannot mean anything.
1551            bail!(
1552                "no open window with key {} (it may have closed)",
1553                req.reference_key
1554            );
1555        }
1556        // A target key with no live window is a reportable per-target outcome, not
1557        // a failure of the batch — the tree row may simply be a tick stale.
1558        let targets: Vec<geometry::RegisteredWindow> = req
1559            .target_keys
1560            .iter()
1561            .map(|key| registered_window(&entries, key))
1562            .collect();
1563
1564        let check = req.check;
1565        let mut report = tokio::task::spawn_blocking(move || {
1566            // The backend lives for exactly one op, so its enumeration cache can
1567            // never serve a window that has since moved or closed.
1568            let backend = make_backend();
1569            geometry::reposition(&backend, &reference, &targets, check)
1570        })
1571        .await
1572        .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1573
1574        // Taken out of the report rather than cloned: nothing downstream reads it,
1575        // and the store is its only owner. A dry run leaves the previous batch's
1576        // record intact — it changed nothing, so there is neither something new to
1577        // undo nor something stale to discard.
1578        let undo = std::mem::take(&mut report.undo);
1579        let undoable = !check && !undo.is_empty();
1580        if undoable {
1581            *self
1582                .reposition_undo
1583                .lock()
1584                .unwrap_or_else(PoisonError::into_inner) = undo;
1585        }
1586        log_reposition(&req, &report);
1587        Ok(reposition_reply(&report, undoable))
1588    }
1589
1590    /// Handles the `reposition-undo` op (#1407): put the windows the last
1591    /// `reposition` moved back where they were.
1592    ///
1593    /// Consumes the stored batch, so an undo cannot be replayed onto windows the
1594    /// user has since arranged by hand. Every window is re-resolved from scratch —
1595    /// one that has closed, been renamed, or gone fullscreen in the meantime is
1596    /// reported, not forced.
1597    async fn reposition_undo(&self) -> Result<Value> {
1598        self.reposition_undo_with(geometry::ax::AxBackend::new)
1599            .await
1600    }
1601
1602    /// [`reposition_undo`](Self::reposition_undo) with the backend factory
1603    /// injected, for the same reasons as
1604    /// [`reposition_with`](Self::reposition_with).
1605    async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1606    where
1607        B: geometry::WindowBackend,
1608        F: FnOnce() -> B + Send + 'static,
1609    {
1610        let stored = std::mem::take(
1611            &mut *self
1612                .reposition_undo
1613                .lock()
1614                .unwrap_or_else(PoisonError::into_inner),
1615        );
1616        if stored.is_empty() {
1617            return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1618        }
1619        let entries = self.registry.list();
1620        let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1621            .into_iter()
1622            .map(|(key, frame)| (registered_window(&entries, &key), frame))
1623            .collect();
1624
1625        let report = tokio::task::spawn_blocking(move || {
1626            let backend = make_backend();
1627            geometry::restore(&backend, &restore)
1628        })
1629        .await
1630        .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1631        log_reposition_undo(&report);
1632        Ok(reposition_reply(&report, false))
1633    }
1634}
1635
1636impl Default for WorktreesService {
1637    fn default() -> Self {
1638        Self::new()
1639    }
1640}
1641
1642#[async_trait]
1643impl DaemonService for WorktreesService {
1644    fn name(&self) -> &'static str {
1645        SERVICE_NAME
1646    }
1647
1648    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1649        match op {
1650            "register" => {
1651                let req: RegisterRequest =
1652                    serde_json::from_value(payload).context("invalid `register` payload")?;
1653                if req.key.trim().is_empty() {
1654                    bail!("`register` requires a non-empty `key`");
1655                }
1656                self.registry.register(req);
1657                Ok(json!({ "ok": true }))
1658            }
1659            "heartbeat" => {
1660                let key = require_str(&payload, "key", "heartbeat")?;
1661                let known = self.registry.heartbeat(key);
1662                // A pending close directive (#1277) rides the reply as an
1663                // additive `close` field, taken-and-cleared here so it fires
1664                // exactly once. Omitted when false to keep older windows — which
1665                // read only `known` — byte-identical on the wire.
1666                let mut reply = json!({ "known": known });
1667                if self.registry.take_close_pending(key) {
1668                    reply["close"] = Value::Bool(true);
1669                }
1670                // A pending reload directive (#1417) rides the same reply, on
1671                // the same terms. Deliberately an independent `if`, not an
1672                // `else`: each field then means exactly "this directive was
1673                // pending", and each is taken exactly once regardless of the
1674                // other. The companion resolves a both-set collision by
1675                // checking `close` first, since closing subsumes reloading.
1676                if self.registry.take_reload_pending(key) {
1677                    reply["reload"] = Value::Bool(true);
1678                }
1679                Ok(reply)
1680            }
1681            "unregister" => {
1682                let key = require_str(&payload, "key", "unregister")?;
1683                Ok(json!({ "removed": self.registry.unregister(key) }))
1684            }
1685            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1686            "tree" => {
1687                // The same `{ repos, show_closed }` snapshot the `subscribe`
1688                // stream pushes, so a one-shot `tree` fetch and the live stream
1689                // agree byte-for-byte (the git enumeration runs off-lock on a
1690                // blocking thread inside the helper). Computed fresh here — the
1691                // `tree` op is a rare manual refresh, deliberately bypassing the
1692                // stream's coalescing cache so it never returns a stale view
1693                // (#1303).
1694                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1695            }
1696            "ahead-behind" => {
1697                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1698                // snapshot no longer carries ahead/behind — the dominant
1699                // per-worktree cost when computed eagerly every tick — so a client
1700                // (the extension on expand, `worktrees tree`) asks for it here only
1701                // for the worktrees it is about to show. Batched by path, one op per
1702                // repo expand; the git walks run on a blocking thread.
1703                let paths = payload
1704                    .get("paths")
1705                    .and_then(Value::as_array)
1706                    .map(|arr| {
1707                        arr.iter()
1708                            .filter_map(Value::as_str)
1709                            .map(PathBuf::from)
1710                            .collect::<Vec<_>>()
1711                    })
1712                    .unwrap_or_default();
1713                Ok(json!({ "results": ahead_behind_results(paths).await }))
1714            }
1715            "set-show-closed" => {
1716                // The daemon-backed show/hide-closed toggle (#1301). Setting it
1717                // bumps the change-notify, so every subscribed window re-pushes a
1718                // snapshot carrying the new `show_closed` — reliable cross-window
1719                // sync `context.globalState` could not do.
1720                let show_closed = payload
1721                    .get("show_closed")
1722                    .and_then(Value::as_bool)
1723                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1724                self.registry.set_show_closed(show_closed);
1725                Ok(json!({ "ok": true }))
1726            }
1727            "set-polling" => {
1728                // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1729                // poller resolving its badges (default is off — zero `gh`);
1730                // disabling stops it and drops its badges. `set_polling` bumps the
1731                // change-notify on a real change, so every subscribed window
1732                // re-pushes a `tree` snapshot carrying the new per-repo
1733                // `polling_enabled` — the reliable cross-window sync the
1734                // `set-show-closed` precedent relies on. A changed value is
1735                // persisted so it survives a daemon restart.
1736                let owner = require_str(&payload, "owner", "set-polling")?;
1737                let name = require_str(&payload, "name", "set-polling")?;
1738                let enabled = payload
1739                    .get("enabled")
1740                    .and_then(Value::as_bool)
1741                    .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1742                if owner.trim().is_empty() || name.trim().is_empty() {
1743                    bail!("`set-polling` requires a non-empty `owner` and `name`");
1744                }
1745                if self.registry.set_polling(owner, name, enabled) {
1746                    self.persist_polling_prefs();
1747                }
1748                Ok(json!({ "ok": true }))
1749            }
1750            "open-prs" => {
1751                // Serve "Open Pull Request…" (and the extension's transient badge
1752                // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1753                // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1754                // call instead of each shelling its own (the per-window burn
1755                // #1370/#1389 target). Repo-wide; the client filters by branch for a
1756                // worktree-scoped lookup, and answers a badged branch straight from
1757                // the snapshot (zero `gh`) without ever reaching here.
1758                let owner = require_str(&payload, "owner", "open-prs")?;
1759                let name = require_str(&payload, "name", "open-prs")?;
1760                if owner.trim().is_empty() || name.trim().is_empty() {
1761                    bail!("`open-prs` requires a non-empty `owner` and `name`");
1762                }
1763                Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1764            }
1765            "open" => {
1766                // Focus (or open — VS Code reuses an already-open window) an
1767                // arbitrary worktree folder supplied by a socket client, reusing
1768                // the tray's launcher path: `focus_window` resolves the launcher
1769                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1770                // the absolute-existing-directory guard (which also blocks a
1771                // `-`-leading path being parsed by `code` as a flag). This is the
1772                // one op a socket *writer* can use to spawn `code`; see the
1773                // ADR-0040 threat model (#1266).
1774                let path = require_str(&payload, "path", "open")?;
1775                focus_window(Path::new(path))?;
1776                Ok(json!({ "ok": true }))
1777            }
1778            "close" => {
1779                // Close a worktree's window and (for a linked worktree)
1780                // **delete** it. Destructive, so all git logic stays in the
1781                // daemon (git2, never a shell) and the main working tree is
1782                // refused defensively — the UI gating is not the only guard.
1783                // See ADR-0049 and docs/worktrees-service.md.
1784                let req: CloseRequest =
1785                    serde_json::from_value(payload).context("invalid `close` payload")?;
1786                self.close(req).await
1787            }
1788            "reload" => {
1789                // Signal each target window to reload itself (#1417). Addressed
1790                // by window key like `reposition`, not by path like `close`: a
1791                // reload acts on a *window*, and one tree row is one window,
1792                // whereas a path can be open in several. Nothing here is
1793                // destructive and nothing waits — the directive is marked and
1794                // the reply says only what was *signalled*. See
1795                // docs/worktrees-service.md.
1796                let req: ReloadRequest =
1797                    serde_json::from_value(payload).context("invalid `reload` payload")?;
1798                Ok(self.reload(req))
1799            }
1800            "merge-queue" => {
1801                // Batch-enqueue eligible worktrees' PRs into the GitHub merge
1802                // queue (#1401). Two-phase like `close` (side-effect-free
1803                // eligibility check → confirmed enqueue) and daemon-re-validated,
1804                // but a single batched op over `paths`. All git/`gh` work runs on
1805                // a blocking thread; enqueue authenticates through the user's own
1806                // `gh`. See ADR-0056 and docs/worktrees-service.md.
1807                let req: MergeQueueRequest =
1808                    serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1809                self.merge_queue(req).await
1810            }
1811            "rebase" => {
1812                // Batch-rebase the selected worktrees onto their repo's remote
1813                // default branch, fetching once per repo (#1415). Two-phase like
1814                // `close`/`merge-queue` (side-effect-free plan → confirmed
1815                // execute) and daemon-re-validated. The fetch authenticates
1816                // through the user's own `ssh-agent`, which launchd exports into
1817                // the daemon's environment — the premise ADR-0055 got wrong. All
1818                // git work runs on a blocking thread. See ADR-0059, ADR-0055 and
1819                // docs/worktrees-service.md.
1820                let req: RebaseRequest =
1821                    serde_json::from_value(payload).context("invalid `rebase` payload")?;
1822                self.rebase(req).await
1823            }
1824            "reposition" => {
1825                // Move each target's already-open VS Code window onto the invoking
1826                // window's geometry (#1407). The one op that reaches outside the
1827                // process to control another application's windows, so all of its
1828                // OS interaction is confined to the `geometry::ax` module behind
1829                // the macOS Accessibility permission — geometry only, never a
1830                // raise, so Z-order is untouched. All AX I/O runs on a blocking
1831                // thread. See ADR-0058 and docs/worktrees-service.md.
1832                let req: RepositionRequest =
1833                    serde_json::from_value(payload).context("invalid `reposition` payload")?;
1834                self.reposition(req).await
1835            }
1836            "reposition-undo" => {
1837                // Put the windows the last `reposition` moved back where they were
1838                // (#1407). Payload-free: the daemon holds the one-level undo
1839                // record, so the client cannot ask to restore arbitrary geometry.
1840                self.reposition_undo().await
1841            }
1842            other => bail!("unknown worktrees op: {other}"),
1843        }
1844    }
1845
1846    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1847        // The single streaming op: a live push of the repo/worktree `tree`
1848        // snapshot. Every other op falls through to the request→reply `handle`.
1849        if op != "subscribe" {
1850            return None;
1851        }
1852        Some(Box::new(WorktreesStream {
1853            // Every stream reads through the one shared cache, so N windows
1854            // sampling the same tick build the tree once, not N times (#1303).
1855            cache: self.tree_cache.clone(),
1856            // Capture the change source *now* — before the server takes its
1857            // initial snapshot — so a change racing that snapshot still wakes us.
1858            changes: self.registry.subscribe_changes(),
1859        }))
1860    }
1861
1862    fn menu(&self) -> MenuSnapshot {
1863        // Serve the snapshot the background task maintains off the main thread;
1864        // fall back to a one-off inline compute only before the first refresh
1865        // lands (or with no runtime — the unit tests). Never blocks on git here
1866        // in the daemon, honouring the trait's "cheap, must not block" contract.
1867        let cached = self
1868            .menu_cache
1869            .lock()
1870            .unwrap_or_else(PoisonError::into_inner)
1871            .clone();
1872        let items = cached.unwrap_or_else(|| {
1873            menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1874        });
1875        MenuSnapshot {
1876            title: SUBMENU_TITLE.to_string(),
1877            items,
1878        }
1879    }
1880
1881    async fn menu_action(&self, action_id: &str) -> Result<()> {
1882        if let Some(key) = action_id.strip_prefix("focus:") {
1883            // The registry resolves the folder under its own lock and clones it
1884            // out, so the mutex is never held across the process launch.
1885            let folder = self
1886                .registry
1887                .first_folder(key)
1888                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
1889            focus_window(&folder)?;
1890            return Ok(());
1891        }
1892        bail!("unknown worktrees menu action: {action_id}")
1893    }
1894
1895    async fn status(&self) -> ServiceStatus {
1896        let entries = self.registry.list();
1897        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
1898        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
1899        let windows = enriched_windows(entries).await;
1900        ServiceStatus {
1901            name: SERVICE_NAME.to_string(),
1902            healthy: true,
1903            summary,
1904            detail: json!({ "windows": windows }),
1905        }
1906    }
1907
1908    async fn shutdown(&self) {
1909        // Stop the background menu-refresh task; the registry itself is in-memory
1910        // with nothing to drain or persist. Take the task out from under the lock
1911        // first so the `std::Mutex` is never held across the `.await`.
1912        let task = self
1913            .refresh
1914            .lock()
1915            .unwrap_or_else(PoisonError::into_inner)
1916            .take();
1917        if let Some(task) = task {
1918            task.token.cancel();
1919            let _ = task.handle.await;
1920        }
1921        // Same discipline for the PR badge poller (#1337): take it out from under
1922        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
1923        let poller = self
1924            .poller
1925            .lock()
1926            .unwrap_or_else(PoisonError::into_inner)
1927            .take();
1928        if let Some(poller) = poller {
1929            poller.token.cancel();
1930            let _ = poller.handle.await;
1931        }
1932        // And the GitHub rate-limit poller (#1375), same discipline.
1933        let rate_limit_poller = self
1934            .rate_limit_poller
1935            .lock()
1936            .unwrap_or_else(PoisonError::into_inner)
1937            .take();
1938        if let Some(poller) = rate_limit_poller {
1939            poller.token.cancel();
1940            let _ = poller.handle.await;
1941        }
1942    }
1943}
1944
1945/// Extracts a required string `field` from an op payload, erroring with the op
1946/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
1947/// (`key`) and `open` (`path`).
1948fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
1949    payload
1950        .get(field)
1951        .and_then(Value::as_str)
1952        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
1953}
1954
1955/// The live git state of a worktree folder: the checked-out branch and how far
1956/// it has diverged from its upstream. Computed on read from the on-disk repo
1957/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
1958/// snapshot taken at registration.
1959///
1960/// Every field is optional and degrades independently: a folder that is not a
1961/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
1962/// listed — just without the fields it cannot supply. The `skip_serializing_if`
1963/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
1964/// omitting each absent field on the wire.
1965#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
1966struct GitStatus {
1967    /// The checked-out branch, or `None` when detached or not in a repo.
1968    #[serde(skip_serializing_if = "Option::is_none")]
1969    branch: Option<String>,
1970    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
1971    /// even on a detached HEAD, which has a commit but no branch. Rides the
1972    /// streamed snapshot so a new commit is a real delta the server's diff cannot
1973    /// drop — without it, a push serialises byte-identically and no client
1974    /// re-renders (#1337).
1975    #[serde(skip_serializing_if = "Option::is_none")]
1976    head_sha: Option<String>,
1977    /// The commit the branch's configured upstream ref points at, or `None`
1978    /// without an upstream (or when detached, unborn, or not in a repo). Rides
1979    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
1980    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
1981    /// field — `head_sha` included — byte-identical, so without this the frame
1982    /// serialised the same, the server's diff dropped it, and the lazily-fetched
1983    /// ahead/behind was never re-asked (#1344).
1984    #[serde(skip_serializing_if = "Option::is_none")]
1985    upstream_sha: Option<String>,
1986    /// Commits the branch is ahead of its upstream (`None` without an upstream).
1987    #[serde(skip_serializing_if = "Option::is_none")]
1988    ahead: Option<usize>,
1989    /// Commits the branch is behind its upstream (`None` without an upstream).
1990    #[serde(skip_serializing_if = "Option::is_none")]
1991    behind: Option<usize>,
1992    /// The main repository's directory name — the parent repo for a linked
1993    /// worktree, the checkout's own directory otherwise. Derived from git's
1994    /// common dir so a worktree names the repo it belongs to rather than its
1995    /// worktree-folder basename. `None` when not in a repo.
1996    #[serde(skip_serializing_if = "Option::is_none")]
1997    main_repo: Option<String>,
1998    /// Whether the enriched folder is a **linked** git worktree rather than the
1999    /// repository's main working tree. Omitted (false) for a normal checkout.
2000    #[serde(skip_serializing_if = "is_false")]
2001    is_worktree: bool,
2002    /// The multi-step git operation the worktree is in the middle of (#1415):
2003    /// `rebase`, `rebase-interactive`, `merge`, `cherry-pick`, `revert`, `bisect`
2004    /// or `apply-mailbox`. `None` for a clean worktree (the overwhelming case), so
2005    /// the field is omitted on the wire and an older client is byte-identical.
2006    ///
2007    /// This is the **durable** half of the tree's rebase cue: read fresh off disk
2008    /// on every snapshot, it survives a daemon restart and keeps showing a
2009    /// conflict the `rebase` op left in place until the user resolves it. The
2010    /// transient half — "the daemon is rebasing this right now" — comes from the
2011    /// registry's in-memory set instead (see [`TreeWorktree::rebasing`]).
2012    ///
2013    /// Cheap enough for the every-worktree-every-tick snapshot (#1306's bar):
2014    /// `Repository::state()` stats a handful of paths under `.git`, which is
2015    /// nothing beside the `Repository::discover` this function already does — and
2016    /// unlike `graph_ahead_behind` it is neither a revwalk nor an object lookup.
2017    #[serde(skip_serializing_if = "Option::is_none")]
2018    operation: Option<String>,
2019}
2020
2021/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
2022/// field is dropped on the wire unless set — keeping older clients byte-identical
2023/// (the protocol's forward-compatibility convention).
2024#[allow(clippy::trivially_copy_pass_by_ref)]
2025fn is_false(b: &bool) -> bool {
2026    !*b
2027}
2028
2029/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
2030/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
2031/// daemon restart within the lease window keep the *remaining* time rather than
2032/// resetting the 15-minute clock; an already-expired entry is dropped on load.
2033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2034struct PollingLease {
2035    repo: String,
2036    expires_at: DateTime<Utc>,
2037}
2038
2039/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
2040/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
2041/// absence means not-polled (the default-off model) — so the file stays small (a
2042/// handful of active repos out of many open). `#[serde(default)]` so an
2043/// empty/older file decodes to "nothing enabled".
2044#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2045struct PollingPrefs {
2046    #[serde(default)]
2047    enabled: Vec<PollingLease>,
2048}
2049
2050/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
2051/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
2052/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
2053fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2054    if let Some(parent) = path.parent() {
2055        crate::daemon::paths::ensure_dir_0700(parent)?;
2056    }
2057    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2058    crate::daemon::paths::write_file_0600(path, &json)
2059}
2060
2061// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
2062
2063/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
2064///
2065/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
2066/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
2067/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
2068/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
2069/// compared against the worktree's current HEAD via
2070/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
2071/// `head_oid` would render every restored badge stale — so it carries the field
2072/// explicitly under a stable snake_case name.
2073#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2074struct PersistedBadge {
2075    number: u64,
2076    is_draft: bool,
2077    checks: PrCheckState,
2078    url: String,
2079    head_oid: String,
2080}
2081
2082/// A persisted resolution — the disk twin of
2083/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
2084/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
2085/// `{ "Pr": { … } }`.
2086#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2087enum PersistedResolution {
2088    Pr(PersistedBadge),
2089    NoPr,
2090}
2091
2092/// One persisted cache entry: which target, and the verdict last resolved for it.
2093#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2094struct PersistedEntry {
2095    target: PrTarget,
2096    resolution: PersistedResolution,
2097}
2098
2099/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
2100/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
2101/// immediate re-poll" from "a target was added or a branch pushed while we were
2102/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
2103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2104struct PersistedWatch {
2105    target: PrTarget,
2106    #[serde(default, skip_serializing_if = "Option::is_none")]
2107    upstream_sha: Option<String>,
2108}
2109
2110/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
2111/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
2112/// so an empty/older/partial file decodes to "nothing restored" rather than
2113/// failing the load — best-effort, exactly like [`PollingPrefs`].
2114#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2115struct PrCachePrefs {
2116    #[serde(default)]
2117    entries: Vec<PersistedEntry>,
2118    #[serde(default)]
2119    watched: Vec<PersistedWatch>,
2120    #[serde(default, skip_serializing_if = "Option::is_none")]
2121    polled_at: Option<DateTime<Utc>>,
2122}
2123
2124impl PersistedResolution {
2125    /// The disk form of a live resolution.
2126    fn from_resolution(r: &PrResolution) -> Self {
2127        match r {
2128            PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2129                number: b.number,
2130                is_draft: b.is_draft,
2131                checks: b.checks,
2132                url: b.url.clone(),
2133                head_oid: b.head_oid.clone(),
2134            }),
2135            PrResolution::NoPr => Self::NoPr,
2136        }
2137    }
2138
2139    /// The live form of a restored resolution.
2140    fn into_resolution(self) -> PrResolution {
2141        match self {
2142            Self::Pr(b) => PrResolution::Pr(PrBadge {
2143                number: b.number,
2144                is_draft: b.is_draft,
2145                checks: b.checks,
2146                url: b.url,
2147                head_oid: b.head_oid,
2148            }),
2149            Self::NoPr => PrResolution::NoPr,
2150        }
2151    }
2152}
2153
2154/// Assembles the on-disk cache from the live cache entries, the watch they were
2155/// resolved for, and the poll time.
2156fn pr_cache_prefs_from(
2157    entries: Vec<(PrTarget, PrResolution)>,
2158    watched: &[PrWatch],
2159    polled_at: DateTime<Utc>,
2160) -> PrCachePrefs {
2161    let mut entries: Vec<PersistedEntry> = entries
2162        .into_iter()
2163        .map(|(target, resolution)| PersistedEntry {
2164            target,
2165            resolution: PersistedResolution::from_resolution(&resolution),
2166        })
2167        .collect();
2168    // Stable on-disk order so the file does not churn on rewrite from `HashMap`
2169    // iteration order alone (the same reason `PollingPrefs` sorts).
2170    entries.sort_by(|a, b| a.target.cmp(&b.target));
2171    let mut watched: Vec<PersistedWatch> = watched
2172        .iter()
2173        .map(|w| PersistedWatch {
2174            target: w.target.clone(),
2175            upstream_sha: w.upstream_sha.clone(),
2176        })
2177        .collect();
2178    watched.sort_by(|a, b| a.target.cmp(&b.target));
2179    PrCachePrefs {
2180        entries,
2181        watched,
2182        polled_at: Some(polled_at),
2183    }
2184}
2185
2186/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
2187/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
2188fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2189    if let Some(parent) = path.parent() {
2190        crate::daemon::paths::ensure_dir_0700(parent)?;
2191    }
2192    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2193    crate::daemon::paths::write_file_0600(path, &json)
2194}
2195
2196/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
2197/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
2198/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
2199/// in-memory cache is authoritative for the running daemon and losing the warm
2200/// start only costs one extra poll after the next restart.
2201fn persist_pr_cache(
2202    path: &Path,
2203    pr_cache: &PrStatusCache,
2204    watched: &[PrWatch],
2205    polled_at: DateTime<Utc>,
2206) {
2207    let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2208    if let Err(err) = write_pr_cache(path, &prefs) {
2209        let at = path.display();
2210        tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2211    }
2212}
2213
2214/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
2215/// the watch set the previous daemon last resolved and when it last polled. The
2216/// poller seeds its loop from this so a restart within the backoff window skips
2217/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
2218/// call to re-derive what the `0600` file already carries.
2219#[derive(Debug, Clone)]
2220struct PrWarmStart {
2221    /// The `(target, upstream_sha)` set the persisted verdicts describe.
2222    watched: Vec<PrWatch>,
2223    /// When those verdicts were resolved, used to age the warm start against the
2224    /// backoff (a stale-enough file just re-polls).
2225    polled_at: DateTime<Utc>,
2226}
2227
2228// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
2229
2230/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
2231/// fetched, for TTL expiry.
2232#[derive(Debug, Clone)]
2233struct OpenPrEntry {
2234    at: Instant,
2235    prs: Vec<Value>,
2236}
2237
2238/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
2239///
2240/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
2241/// from the daemon means N windows asking about one repo dedupe to a single counted
2242/// `gh pr list` within the TTL, instead of each window shelling its own (the
2243/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
2244/// the access pattern is a manual action (or a brief post-enable transient), so two
2245/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
2246/// and harmless, while the common repeat-within-TTL is served for free. The lock is
2247/// never held across an `.await`.
2248#[derive(Debug)]
2249struct OpenPrCache {
2250    entries: Mutex<HashMap<String, OpenPrEntry>>,
2251    ttl: Duration,
2252}
2253
2254impl OpenPrCache {
2255    fn new(ttl: Duration) -> Self {
2256        Self {
2257            entries: Mutex::new(HashMap::new()),
2258            ttl,
2259        }
2260    }
2261
2262    /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
2263    /// `None` (a miss that the caller resolves with a fresh `gh`).
2264    fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2265        self.entries
2266            .lock()
2267            .unwrap_or_else(PoisonError::into_inner)
2268            .get(key)
2269            .filter(|e| e.at.elapsed() < self.ttl)
2270            .map(|e| e.prs.clone())
2271    }
2272
2273    /// Records a freshly-fetched PR list for `key`.
2274    fn store(&self, key: String, prs: Vec<Value>) {
2275        self.entries
2276            .lock()
2277            .unwrap_or_else(PoisonError::into_inner)
2278            .insert(
2279                key,
2280                OpenPrEntry {
2281                    at: Instant::now(),
2282                    prs,
2283                },
2284            );
2285    }
2286}
2287
2288/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
2289/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
2290/// call on a blocking thread, never an async worker. The array is forwarded to the
2291/// extension verbatim, which parses it into its `PullRequest` shape.
2292fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2293    let output = crate::github_metrics::run_gh(
2294        bin,
2295        [
2296            "pr",
2297            "list",
2298            "--repo",
2299            slug,
2300            "--state",
2301            "open",
2302            "--json",
2303            OPEN_PR_JSON_FIELDS,
2304            "--limit",
2305            OPEN_PR_LIST_LIMIT,
2306        ],
2307        "pr list",
2308        None,
2309    )
2310    .with_context(|| {
2311        format!(
2312            "failed to run {} (is the GitHub CLI installed?)",
2313            bin.display()
2314        )
2315    })?;
2316    if !output.status.success() {
2317        let stderr = String::from_utf8_lossy(&output.stderr);
2318        bail!("gh pr list failed: {}", stderr.trim());
2319    }
2320    match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2321        Value::Array(arr) => Ok(arr),
2322        _ => bail!("gh pr list did not return a JSON array"),
2323    }
2324}
2325
2326/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
2327/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
2328/// op and the tray menu, both bounded to the (few) open windows, where the extra
2329/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
2330/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
2331fn git_status(folder: &Path) -> GitStatus {
2332    git_status_impl(folder, true)
2333}
2334
2335/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
2336/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
2337/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
2338/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
2339/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
2340/// fields stay `None`, so they are omitted on the wire exactly as for a branch
2341/// with no upstream.
2342fn git_status_cheap(folder: &Path) -> GitStatus {
2343    git_status_impl(folder, false)
2344}
2345
2346/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
2347/// repository that contains `folder` — so a subdirectory or a linked worktree both
2348/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
2349/// divergence. Every failure mode degrades to an empty status rather than
2350/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
2351fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2352    let Ok(repo) = Repository::discover(folder) else {
2353        return GitStatus::default();
2354    };
2355    // Repo identity applies even when HEAD is unborn or detached, so a worktree
2356    // still names its parent repo (and is flagged as a worktree) in those states.
2357    // The in-progress operation is read here too, for the same reason: a worktree
2358    // mid-rebase has a *detached* HEAD, so reading it any later would miss the one
2359    // state the cue exists to show.
2360    let base = GitStatus {
2361        main_repo: main_repo_name(repo.commondir()),
2362        is_worktree: repo.is_worktree(),
2363        operation: operation_slug(repo.state()),
2364        ..GitStatus::default()
2365    };
2366    let Ok(head) = repo.head() else {
2367        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
2368        return base;
2369    };
2370    // Resolved here — before the branch filter below, so a detached HEAD still
2371    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
2372    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
2373    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
2374    // rebuild (#1306's bar).
2375    let base = GitStatus {
2376        head_sha: head.target().map(|oid| oid.to_string()),
2377        ..base
2378    };
2379    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
2380    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
2381    // name — degrades to no branch through this one path.
2382    let Some(name) = head
2383        .shorthand()
2384        .ok()
2385        .filter(|_| head.is_branch())
2386        .map(str::to_string)
2387    else {
2388        return base;
2389    };
2390    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
2391    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
2392    // below costs the cheap path nothing, and is what gives it a handle to
2393    // resolve the upstream from.
2394    let branch = git2::Branch::wrap(head);
2395    // Unlike the divergence walk, this rides both paths: it is what makes a push
2396    // a visible delta (#1344).
2397    let upstream_sha = upstream_target(&branch);
2398    // The divergence walk is the dominant per-worktree cost, so the cheap path
2399    // skips it and leaves ahead/behind absent.
2400    let (ahead, behind) = if with_ahead_behind {
2401        match upstream_ahead_behind(&repo, &branch) {
2402            Some((ahead, behind)) => (Some(ahead), Some(behind)),
2403            None => (None, None),
2404        }
2405    } else {
2406        (None, None)
2407    };
2408    GitStatus {
2409        branch: Some(name),
2410        upstream_sha,
2411        ahead,
2412        behind,
2413        ..base
2414    }
2415}
2416
2417/// The stable kebab-case slug for a repository's in-progress operation, or `None`
2418/// when it is [`RepositoryState::Clean`] (#1415).
2419///
2420/// The three rebase flavours libgit2 distinguishes (`Rebase`, `RebaseInteractive`,
2421/// `RebaseMerge`) all collapse to `rebase-interactive` or `rebase`, because the
2422/// distinction is an implementation detail of how git is driving the rebase and
2423/// says nothing a user acting on the row would do differently. The `*Sequence`
2424/// variants likewise fold into their singular form. Everything a client does not
2425/// recognise still renders as "some operation in progress", which is the useful
2426/// floor.
2427fn operation_slug(state: RepositoryState) -> Option<String> {
2428    let slug = match state {
2429        RepositoryState::Clean => return None,
2430        RepositoryState::Merge => "merge",
2431        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2432        RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2433        RepositoryState::Bisect => "bisect",
2434        RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2435        RepositoryState::RebaseInteractive => "rebase-interactive",
2436        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2437    };
2438    Some(slug.to_string())
2439}
2440
2441/// The commit `branch`'s configured upstream ref points at, or `None` when it
2442/// tracks no upstream (or the ref is unresolvable).
2443///
2444/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
2445/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
2446/// refs read, but still **no revwalk and no object lookup**, which is the bar
2447/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
2448/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
2449/// same OID, so it is proven reachable.
2450fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2451    Some(branch.upstream().ok()?.get().target()?.to_string())
2452}
2453
2454/// The ahead/behind divergence of `folder`'s checked-out branch versus its
2455/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
2456/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
2457/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
2458/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
2459/// sync indicator.
2460fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2461    let repo = Repository::discover(folder).ok()?;
2462    let head = repo.head().ok()?;
2463    if !head.is_branch() {
2464        return None;
2465    }
2466    let branch = git2::Branch::wrap(head);
2467    upstream_ahead_behind(&repo, &branch)
2468}
2469
2470/// The main repository's directory name from git's common dir. For the usual
2471/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
2472/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
2473/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
2474/// no name can be derived.
2475fn main_repo_name(commondir: &Path) -> Option<String> {
2476    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2477    if file_name == ".git" {
2478        // Normal layout: the repo is the directory that contains `.git`.
2479        commondir
2480            .parent()
2481            .and_then(Path::file_name)
2482            .map(|n| n.to_string_lossy().into_owned())
2483    } else {
2484        // A bare repo: use its own directory name, without any `.git` suffix.
2485        Some(
2486            file_name
2487                .strip_suffix(".git")
2488                .unwrap_or(&file_name)
2489                .to_string(),
2490        )
2491    }
2492}
2493
2494/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2495/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2496fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2497    let upstream = branch.upstream().ok()?;
2498    let local_oid = branch.get().target()?;
2499    let upstream_oid = upstream.get().target()?;
2500    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2501}
2502
2503/// The wire shape of an enriched window: the stored entry fields plus the
2504/// daemon-computed git state, flattened into one JSON object. Serializing
2505/// through a single struct (rather than mutating a `Value`) keeps every present
2506/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2507/// the absent git fields — no manual per-field insertion.
2508#[derive(Serialize)]
2509struct EnrichedEntry<'a> {
2510    #[serde(flatten)]
2511    entry: &'a WindowEntry,
2512    #[serde(flatten)]
2513    git: GitStatus,
2514}
2515
2516/// Serializes a registry entry and folds in the live [`git_status`] of its
2517/// primary (first) folder, producing the JSON object served on the wire
2518/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2519/// enriched — it is the one the table shows and the "focus" action opens.
2520fn enriched_entry(entry: &WindowEntry) -> Value {
2521    let git = entry
2522        .folders
2523        .first()
2524        .map(|folder| git_status(folder))
2525        .unwrap_or_default();
2526    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2527}
2528
2529/// Enriches a batch of entries with their git state on a blocking thread, since
2530/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2531/// handler. A join failure degrades to an empty list rather than erroring.
2532async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2533    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2534        .await
2535        .unwrap_or_default()
2536}
2537
2538// --- Repo/worktree tree (#1265) ----------------------------------------------
2539
2540/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2541/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2542/// non-GitHub (or remote-less) repo omits it.
2543#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2544struct GithubIdentity {
2545    /// The repository owner (user or org) — the first path segment.
2546    owner: String,
2547    /// The repository name, with any `.git` suffix stripped.
2548    name: String,
2549}
2550
2551/// One worktree of a repository in the `tree` payload: its path, live git state,
2552/// whether it is the main working tree, and whether a VS Code window currently
2553/// has it open (with that window's key, for the focus action). Optional git
2554/// fields degrade independently, exactly like [`GitStatus`].
2555///
2556/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2557/// the dominant per-worktree cost when computed eagerly for every worktree on
2558/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2559/// worktrees a client actually shows (#1306).
2560///
2561/// The two **OIDs** the divergence is computed from — `head_sha` and
2562/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2563/// refs read rather than a commit-graph walk, and between them they are what makes
2564/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2565/// re-ask for the counts it left behind.
2566#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2567struct TreeWorktree {
2568    /// Absolute path to the worktree's working directory.
2569    path: String,
2570    /// The checked-out branch, or `None` when detached or unborn.
2571    #[serde(skip_serializing_if = "Option::is_none")]
2572    branch: Option<String>,
2573    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2574    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2575    /// commit a visible delta, so a push re-renders instead of being dropped by the
2576    /// server's snapshot diff (#1337).
2577    #[serde(skip_serializing_if = "Option::is_none")]
2578    head_sha: Option<String>,
2579    /// The commit the branch's upstream ref points at, or `None` without an
2580    /// upstream. The push counterpart of `head_sha`: a push moves only
2581    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2582    /// making the snapshot a real delta the server's diff cannot drop, which is
2583    /// what re-fetches the lazy ahead/behind (#1344).
2584    #[serde(skip_serializing_if = "Option::is_none")]
2585    upstream_sha: Option<String>,
2586    /// Whether this is the repository's main working tree (vs a linked worktree).
2587    is_main: bool,
2588    /// Whether a live VS Code window currently has this worktree open.
2589    open: bool,
2590    /// The open window's registry key, when `open` — the handle a focus action
2591    /// resolves. Absent for a worktree with no open window.
2592    #[serde(skip_serializing_if = "Option::is_none")]
2593    window_key: Option<String>,
2594    /// The open PR whose head is this worktree's branch, with its CI verdict
2595    /// (#1337). Resolved by the daemon's background poller and folded on as the
2596    /// snapshot is built, so every open window sees the same live state without
2597    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2598    /// with no open PR (see `pr_none`), or until the first poll lands.
2599    #[serde(skip_serializing_if = "Option::is_none")]
2600    pr: Option<PrBadge>,
2601    /// Set when the daemon **checked GitHub and found no open PR** for this
2602    /// worktree's branch — the explicit negative (#1370), mutually exclusive
2603    /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2604    /// non-GitHub worktree, and — crucially — while the branch is simply **not
2605    /// yet resolved** (before the first poll lands, or ever, on a failed one):
2606    /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2607    /// older client stays byte-identical (ADR-0053). Clients use it to keep
2608    /// their degraded per-window `gh pr list` fallback quiet for branches the
2609    /// daemon has already answered for.
2610    #[serde(skip_serializing_if = "is_false")]
2611    pr_none: bool,
2612    /// The multi-step git operation this worktree is mid-way through, when any
2613    /// (#1415) — see [`GitStatus::operation`]. The **durable** half of the rebase
2614    /// cue: a conflict the `rebase` op left in place shows here until it is
2615    /// resolved, across daemon restarts. Omitted for a clean worktree.
2616    #[serde(skip_serializing_if = "Option::is_none")]
2617    operation: Option<String>,
2618    /// Whether the daemon is rebasing this worktree **right now** (#1415) — the
2619    /// **transient** half of the cue, from the registry's in-memory set.
2620    ///
2621    /// Not redundant with `operation`: a rebase that applies cleanly never leaves
2622    /// an on-disk state for `operation` to report, and even one that conflicts
2623    /// only writes it at the moment of collision — so without this a multi-second
2624    /// rebase would render as nothing happening at all. Omitted (false) for the
2625    /// common case, keeping an older client byte-identical.
2626    #[serde(skip_serializing_if = "is_false")]
2627    rebasing: bool,
2628}
2629
2630/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2631/// derived from the distinct open windows; a repo leaves the tree when its last
2632/// window closes (the open-window-derived model, ADR-0040 / #1264).
2633#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2634struct TreeRepo {
2635    /// The main repository's directory name (see [`main_repo_name`]).
2636    main_repo: String,
2637    /// The GitHub identity of `origin`, when it is a `github.com` remote.
2638    #[serde(skip_serializing_if = "Option::is_none")]
2639    github: Option<GithubIdentity>,
2640    /// Absolute path to the main working tree — the repo's root.
2641    root: String,
2642    /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2643    /// registry's per-repo enable set, which defaults **off**, so it is omitted
2644    /// (false) for the common not-polled repo — keeping older clients
2645    /// byte-identical — and present (`true`) only for a repo the user has
2646    /// explicitly enabled. The extension colours the repo icon green when set and
2647    /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2648    /// on it so a not-polled repo issues zero `gh`.
2649    #[serde(skip_serializing_if = "is_false")]
2650    polling_enabled: bool,
2651    /// Every worktree of the repo: the main working tree first, then linked
2652    /// worktrees sorted by path.
2653    worktrees: Vec<TreeWorktree>,
2654}
2655
2656/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2657/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2658/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2659/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2660/// slashes are stripped; anything with an empty or extra path segment is
2661/// rejected (best-effort, never panics).
2662fn github_identity(url: &str) -> Option<GithubIdentity> {
2663    let url = url.trim();
2664    // Reduce every supported form to the `owner/name…` tail after the host.
2665    let rest = [
2666        "https://github.com/",
2667        "http://github.com/",
2668        "ssh://git@github.com/",
2669        "git://github.com/",
2670        "git@github.com:",
2671    ]
2672    .iter()
2673    .find_map(|prefix| url.strip_prefix(prefix))?;
2674    let rest = rest.strip_suffix(".git").unwrap_or(rest);
2675    let rest = rest.trim_end_matches('/');
2676    let mut parts = rest.splitn(2, '/');
2677    let owner = parts.next()?.trim();
2678    let name = parts.next()?.trim();
2679    // A well-formed identity has exactly two non-empty segments.
2680    if owner.is_empty() || name.is_empty() || name.contains('/') {
2681        return None;
2682    }
2683    Some(GithubIdentity {
2684        owner: owner.to_string(),
2685        name: name.to_string(),
2686    })
2687}
2688
2689/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2690/// `github.com` remote found. `None` when no remote is a GitHub remote.
2691fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2692    if let Ok(origin) = repo.find_remote("origin") {
2693        if let Some(id) = origin.url().ok().and_then(github_identity) {
2694            return Some(id);
2695        }
2696    }
2697    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2698    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2699    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2700    let names = repo.remotes().ok();
2701    names
2702        .iter()
2703        .flat_map(|arr| arr.iter())
2704        .flatten()
2705        .flatten()
2706        .filter_map(|name| repo.find_remote(name).ok())
2707        .find_map(|remote| remote.url().ok().and_then(github_identity))
2708}
2709
2710/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2711/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2712/// no longer exists) so the join still degrades gracefully.
2713fn canonical(path: &Path) -> PathBuf {
2714    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2715}
2716
2717/// Indexes the open windows by canonicalized workspace-folder path → window key,
2718/// so a worktree path can be joined back to the window (if any) that has it open.
2719/// The first window wins a shared folder; `entries` arrive in a deterministic
2720/// (repo, key) order, so the choice is stable.
2721fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2722    let mut index = HashMap::new();
2723    for entry in entries {
2724        for folder in &entry.folders {
2725            index
2726                .entry(canonical(folder))
2727                .or_insert_with(|| entry.key.clone());
2728        }
2729    }
2730    index
2731}
2732
2733/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2734/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2735/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2736/// by the caller from the enumeration (main working tree vs linked).
2737fn worktree_entry(
2738    path: &Path,
2739    is_main: bool,
2740    open_index: &HashMap<PathBuf, String>,
2741    rebasing: &HashSet<PathBuf>,
2742) -> TreeWorktree {
2743    let status = git_status_cheap(path);
2744    let canonical = canonical(path);
2745    let window_key = open_index.get(&canonical).cloned();
2746    TreeWorktree {
2747        path: path.display().to_string(),
2748        branch: status.branch,
2749        head_sha: status.head_sha,
2750        upstream_sha: status.upstream_sha,
2751        is_main,
2752        open: window_key.is_some(),
2753        window_key,
2754        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2755        // identity — known one level up, in `repo_tree`.
2756        pr: None,
2757        pr_none: false,
2758        operation: status.operation,
2759        // Both halves of the rebase cue are joined on the *canonical* path: the
2760        // registry set is canonicalized by the adapter when the op marks it.
2761        rebasing: rebasing.contains(&canonical),
2762    }
2763}
2764
2765/// Folds the poller's cached PR resolutions onto each worktree of each repo
2766/// (#1337).
2767///
2768/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2769/// identity, branch) and the identity is only known once the repo is assembled.
2770/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2771/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
2772/// keeps `pr: None`/`pr_none: false` and renders nothing.
2773///
2774/// A verdict computed for a **different commit** than the worktree has checked out
2775/// is downgraded to pending here rather than shown as-is. That is what makes a push
2776/// invalidate the badge the moment it happens: the cache still holds the previous
2777/// commit's verdict, and this fold — which runs on every snapshot — notices without
2778/// waiting for a poll. Without it the previous head's `✓` stands until the poller
2779/// next runs, which is up to the full backoff.
2780///
2781/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
2782/// when `head_sha` moves: it has no commit to be stale against, and dropping it
2783/// would re-arm every client's `gh` fallback on every local commit. The poller's
2784/// `moved` trigger re-checks the branch within one fast poll anyway.
2785/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
2786/// enable set (#1376).
2787///
2788/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
2789/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
2790/// enabled carries neither the flag nor any badge. Purely a set membership check —
2791/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
2792/// and stays `false`; it never polls anyway.
2793fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2794    for repo in repos {
2795        if let Some(github) = &repo.github {
2796            repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
2797        }
2798    }
2799}
2800
2801fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
2802    for repo in repos {
2803        // A not-polled repo (#1376) never carries a badge: skip it so a repo the
2804        // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
2805        // the flag, and so the icon greys cross-window on the next pushed snapshot.
2806        if !repo.polling_enabled {
2807            continue;
2808        }
2809        let Some(github) = repo.github.clone() else {
2810            continue;
2811        };
2812        for worktree in &mut repo.worktrees {
2813            let Some(branch) = &worktree.branch else {
2814                continue;
2815            };
2816            match pr_cache.get(&github.owner, &github.name, branch) {
2817                Some(PrResolution::Pr(mut badge)) => {
2818                    if badge.is_stale_for(worktree.head_sha.as_deref()) {
2819                        badge.checks = PrCheckState::Pending;
2820                    }
2821                    worktree.pr = Some(badge);
2822                }
2823                Some(PrResolution::NoPr) => worktree.pr_none = true,
2824                None => {}
2825            }
2826        }
2827    }
2828}
2829
2830/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
2831/// handle discovered from one of its folders. Opens the **main** repo from the
2832/// shared common dir's parent so the main working tree and every linked worktree
2833/// are enumerated regardless of which one seeded the discovery. `None` for a
2834/// bare or otherwise root-less repo (no working tree to show).
2835fn repo_tree(
2836    discovered: &Repository,
2837    open_index: &HashMap<PathBuf, String>,
2838    rebasing: &HashSet<PathBuf>,
2839) -> Option<TreeRepo> {
2840    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
2841    // linked worktrees; its parent is the main working tree.
2842    let commondir = canonical(discovered.commondir());
2843    let main_root = commondir.parent()?.to_path_buf();
2844    let main_repo = Repository::open(&main_root).ok()?;
2845
2846    // Main working tree first.
2847    let mut worktrees = vec![worktree_entry(&main_root, true, open_index, rebasing)];
2848    // Then every linked worktree, sorted by path for deterministic output. The
2849    // `StringArray` of names is bound so `iter()` can borrow it (only
2850    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
2851    // worktree is skipped.
2852    let names = main_repo.worktrees().ok();
2853    let mut linked: Vec<PathBuf> = names
2854        .iter()
2855        .flat_map(|arr| arr.iter())
2856        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
2857        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
2858        .filter_map(|name| main_repo.find_worktree(name).ok())
2859        .map(|wt| wt.path().to_path_buf())
2860        .collect();
2861    linked.sort();
2862    worktrees.extend(
2863        linked
2864            .iter()
2865            .map(|path| worktree_entry(path, false, open_index, rebasing)),
2866    );
2867
2868    Some(TreeRepo {
2869        main_repo: main_repo_name(&commondir)?,
2870        github: remote_github_identity(&main_repo),
2871        root: main_root.display().to_string(),
2872        // Defaults off; `stamp_polling` sets it from the registry's enable set
2873        // once the repo (and thus its GitHub identity) is assembled.
2874        polling_enabled: false,
2875        worktrees,
2876    })
2877}
2878
2879/// Resolves the seed `folders` to their distinct repositories and enumerates
2880/// each repo's worktrees. Dedupes repos by their common dir (shared across a
2881/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
2882/// is not in a git repo is skipped. Pure blocking git I/O — call it via
2883/// [`tree_repos`], never under the registry lock.
2884fn build_tree(
2885    folders: Vec<PathBuf>,
2886    windows: Vec<WindowEntry>,
2887    rebasing: HashSet<PathBuf>,
2888) -> Vec<TreeRepo> {
2889    let open_index = open_window_index(&windows);
2890    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
2891    for folder in &folders {
2892        let Ok(repo) = Repository::discover(folder) else {
2893            continue;
2894        };
2895        let key = canonical(repo.commondir());
2896        if repos.contains_key(&key) {
2897            continue;
2898        }
2899        if let Some(tree) = repo_tree(&repo, &open_index, &rebasing) {
2900            repos.insert(key, tree);
2901        }
2902    }
2903    repos.into_values().collect()
2904}
2905
2906/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
2907/// does synchronous disk I/O and this runs inside the async control-socket
2908/// handler), returning the serialized `repos` array. A join failure degrades to
2909/// an empty list rather than erroring, matching [`enriched_windows`].
2910async fn tree_repos(
2911    folders: Vec<PathBuf>,
2912    windows: Vec<WindowEntry>,
2913    pr_cache: Arc<PrStatusCache>,
2914    enabled_polling: HashSet<String>,
2915    rebasing: HashSet<PathBuf>,
2916) -> Vec<Value> {
2917    tokio::task::spawn_blocking(move || {
2918        let mut repos = build_tree(folders, windows, rebasing);
2919        // Stamp per-repo poll state first so `fold_pr_badges` can skip a
2920        // not-polled repo — a disabled repo carries neither the flag nor a badge.
2921        stamp_polling(&mut repos, &enabled_polling);
2922        fold_pr_badges(&mut repos, &pr_cache);
2923        repos
2924            .iter()
2925            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
2926            .collect()
2927    })
2928    .await
2929    .unwrap_or_default()
2930}
2931
2932// --- Lazy ahead/behind (#1306) -----------------------------------------------
2933
2934/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
2935/// returning a JSON object keyed by the **requested** path string:
2936/// `{ "<path>": { "ahead": n, "behind": m }, … }`. A path with no upstream (or that
2937/// is not a repo / is detached) is **omitted** — the client renders it without a
2938/// sync indicator, exactly as the tree does for an absent `ahead`/`behind`.
2939///
2940/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
2941/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
2942/// shows (the extension on expand), not for every worktree on every tick. The git
2943/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
2944/// degrades to an empty object rather than erroring.
2945async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
2946    tokio::task::spawn_blocking(move || {
2947        let mut results = serde_json::Map::new();
2948        for path in paths {
2949            if let Some((ahead, behind)) = folder_ahead_behind(&path) {
2950                results.insert(
2951                    path.display().to_string(),
2952                    json!({ "ahead": ahead, "behind": behind }),
2953                );
2954            }
2955        }
2956        Value::Object(results)
2957    })
2958    .await
2959    .unwrap_or_else(|_| json!({}))
2960}
2961
2962// --- Push subscription (#1267) -----------------------------------------------
2963
2964/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
2965/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
2966/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
2967/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
2968/// type only has to (a) relay the registry's change-notify and (b) read the
2969/// tree snapshot on demand.
2970///
2971/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
2972/// is built at most once per tick and fanned out, rather than each stream
2973/// rebuilding the identical tree. This type holds only cheap handles — a clone
2974/// of the shared cache and its own change-notify receiver.
2975struct WorktreesStream {
2976    /// The shared coalescing cache the snapshot is read through, so every
2977    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
2978    cache: Arc<TreeSnapshotCache>,
2979    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
2980    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
2981    /// server's diff drops any snapshot that ends up identical.
2982    changes: watch::Receiver<u64>,
2983}
2984
2985#[async_trait]
2986impl ServiceStream for WorktreesStream {
2987    async fn changed(&mut self) {
2988        // `watch::Receiver::changed` marks the newest version seen, so a burst of
2989        // bumps collapses into a single wakeup. If every sender is gone (the
2990        // registry — and thus the daemon — is tearing down) it returns `Err`;
2991        // park instead of returning, so this arm can never spin the server's
2992        // `select!` (the tick and shutdown arms still drive teardown).
2993        if self.changes.changed().await.is_err() {
2994            std::future::pending::<()>().await;
2995        }
2996    }
2997
2998    async fn snapshot(&self) -> Value {
2999        // Read through the shared coalescing cache. The value is built by the
3000        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
3001        // live push agree byte-for-byte — but here it is built once per tick and
3002        // shared across every subscriber rather than rebuilt per stream (#1303).
3003        self.cache.snapshot().await
3004    }
3005}
3006
3007/// A coalescing cache for the global tree snapshot (#1303).
3008///
3009/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
3010/// server re-samples each on its own `STREAM_TICK` and on every registry change
3011/// — so with N windows the *identical* global tree was being built N times per
3012/// tick. This cache collapses that to **one** build: all streams share it, and
3013/// it rebuilds at most once per `ttl` (the stream tick) per registry
3014/// change-generation.
3015///
3016/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
3017/// exactly as before:
3018/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
3019///   still matches — a `register`/`unregister`/toggle bumps it and forces a
3020///   fresh build, so subscribers never see a stale visible set; and
3021/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
3022///   branch switch, new commits), which fires no registry event, still surfaces
3023///   within one tick.
3024///
3025/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
3026/// callers, so a burst of N streams waking on the same tick/change performs one
3027/// build while the rest wait and read the shared result. The one-shot `tree` op
3028/// bypasses this and computes fresh — it is a rare manual refresh, not part of
3029/// the per-tick fan-out.
3030struct TreeSnapshotCache {
3031    /// The registry every snapshot is built from, and whose change-generation
3032    /// gates cache reuse.
3033    registry: Arc<WorktreesRegistry>,
3034    /// PR badges folded onto each worktree as the snapshot is built (#1337).
3035    /// Written by the background poller; read here. A miss simply omits `pr`.
3036    pr_cache: Arc<PrStatusCache>,
3037    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
3038    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
3039    /// coalesced build runs at most once per tick; tests inject a shorter value.
3040    ttl: Duration,
3041    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
3042    /// because it is deliberately held across the `.await` of the git
3043    /// enumeration, so concurrent callers serialize onto one build rather than
3044    /// each computing their own.
3045    state: AsyncMutex<Option<CachedTree>>,
3046    /// How many times the tree was actually (re)built — so tests can assert the
3047    /// coalescing collapses an N-stream burst into one build. Cheap and always
3048    /// maintained; only read under `#[cfg(test)]`.
3049    computes: AtomicU64,
3050}
3051
3052/// One cached tree snapshot: the shared value plus the two freshness stamps
3053/// [`TreeSnapshotCache`] checks before reusing it.
3054struct CachedTree {
3055    /// The registry change-generation captured *before* the build, so a change
3056    /// racing the build advances the generation and the next read rebuilds
3057    /// (conservative: it may rebuild once needlessly, but never serves stale).
3058    generation: u64,
3059    /// When the value was built, for the `ttl` staleness check.
3060    computed_at: Instant,
3061    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
3062    /// subscriber by cloning the `Arc`'s inner value.
3063    value: Arc<Value>,
3064}
3065
3066impl TreeSnapshotCache {
3067    /// Creates a cache over `registry` with the default TTL — the server's
3068    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
3069    /// build runs at most once per tick.
3070    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3071        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3072    }
3073
3074    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
3075    /// long) freshness window without waiting a real tick.
3076    fn with_ttl(
3077        registry: Arc<WorktreesRegistry>,
3078        pr_cache: Arc<PrStatusCache>,
3079        ttl: Duration,
3080    ) -> Self {
3081        Self {
3082            registry,
3083            pr_cache,
3084            ttl,
3085            state: AsyncMutex::new(None),
3086            computes: AtomicU64::new(0),
3087        }
3088    }
3089
3090    /// The current tree snapshot, built at most once per `ttl` per registry
3091    /// change-generation and shared across all callers. See the type docs for
3092    /// the freshness and single-flight semantics.
3093    async fn snapshot(&self) -> Value {
3094        // Hold the lock across the whole check-and-build so concurrent callers
3095        // serialize onto one build (single-flight); reading the generation here
3096        // (before the build) means a change racing the build forces the *next*
3097        // read to rebuild rather than serving this now-stale value.
3098        let mut state = self.state.lock().await;
3099        let generation = self.registry.change_generation();
3100        // Reuse the cached value only while it matches the current generation
3101        // *and* is within the TTL; either failing forces a rebuild.
3102        let fresh = state.as_ref().and_then(|cached| {
3103            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3104                .then(|| Arc::clone(&cached.value))
3105        });
3106        let value = if let Some(value) = fresh {
3107            value
3108        } else {
3109            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3110            self.computes.fetch_add(1, Ordering::Relaxed);
3111            *state = Some(CachedTree {
3112                generation,
3113                computed_at: Instant::now(),
3114                value: Arc::clone(&value),
3115            });
3116            value
3117        };
3118        // Release the lock before the (deeper) clone of the shared value out.
3119        drop(state);
3120        (*value).clone()
3121    }
3122
3123    /// How many times the tree was actually built — the coalescing assertion in
3124    /// tests (N reads within one tick/generation should build once).
3125    #[cfg(test)]
3126    fn compute_count(&self) -> u64 {
3127        self.computes.load(Ordering::Relaxed)
3128    }
3129}
3130
3131/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
3132/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
3133/// (the seed folders to derive repos from, and the live windows to join on) and
3134/// a lock-free read of the toggle, then the git enumeration/enrichment off the
3135/// lock on a blocking thread inside [`tree_repos`].
3136async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3137    let folders = registry.open_folders();
3138    let windows = registry.list();
3139    let show_closed = registry.show_closed();
3140    let enabled_polling = registry.enabled_polling_repos();
3141    // The transient half of the rebase cue (#1415), read here with the other cheap
3142    // registry locks so the git work below deals only in plain data.
3143    let rebasing = registry.rebasing_paths();
3144    json!({
3145        "repos": tree_repos(folders, windows, pr_cache, enabled_polling, rebasing).await,
3146        "show_closed": show_closed,
3147    })
3148}
3149
3150/// A short human name for a window: its repo, else its first folder's basename,
3151/// else a placeholder.
3152fn display_name(entry: &WindowEntry) -> String {
3153    if let Some(repo) = &entry.repo {
3154        return repo.clone();
3155    }
3156    if let Some(folder) = entry.folders.first() {
3157        return folder.file_name().map_or_else(
3158            || folder.display().to_string(),
3159            |n| n.to_string_lossy().into_owned(),
3160        );
3161    }
3162    "(no folder)".to_string()
3163}
3164
3165/// Separator between the repo name and branch for a normal working tree.
3166const REPO_SEP: char = '·';
3167/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
3168/// line is distinguishable at a glance from its parent repo's main checkout.
3169const WORKTREE_SEP: char = '⑂';
3170
3171/// The full tray item list for a window set: the "No open windows" placeholder
3172/// when empty, else one line per window via [`window_menu_items`]. Does the git
3173/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
3174/// background refresh task — and inline only as a cold-start fallback in `menu`.
3175fn menu_items_for(
3176    entries: &[WindowEntry],
3177    rate_limit: Option<&RateLimitSnapshot>,
3178) -> Vec<MenuItem> {
3179    let mut items = Vec::new();
3180    // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
3181    // above the windows, so an approaching exhaustion is visible in the tray before
3182    // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
3183    if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3184        if !label.is_empty() {
3185            items.push(MenuItem::Label(label));
3186            items.push(MenuItem::Separator);
3187        }
3188    }
3189    if entries.is_empty() {
3190        items.push(MenuItem::Label("No open windows".to_string()));
3191    } else {
3192        items.extend(window_menu_items(entries));
3193    }
3194    items
3195}
3196
3197/// Builds the tray items for a non-empty window list: **one clickable line per
3198/// window** whose label carries the live git state and whose click focuses that
3199/// window. A window with no workspace folder has nothing for `code` to open, so
3200/// it stays a non-clickable status line. The labels read each worktree from disk
3201/// (via [`window_label`]) — cheap for a realistic window count and consistent
3202/// with reap-on-read.
3203fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3204    entries
3205        .iter()
3206        .map(|entry| {
3207            let label = window_label(entry);
3208            if entry.folders.is_empty() {
3209                MenuItem::Label(label)
3210            } else {
3211                MenuItem::Action(MenuAction {
3212                    id: format!("focus:{}", entry.key),
3213                    label,
3214                    enabled: true,
3215                })
3216            }
3217        })
3218        .collect()
3219}
3220
3221/// The tray label for one window: the **main repository** name, then live branch
3222/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
3223/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
3224/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
3225/// that is not a repo falls back to its reported title.
3226fn window_label(entry: &WindowEntry) -> String {
3227    let status = entry
3228        .folders
3229        .first()
3230        .map(|folder| git_status(folder))
3231        .unwrap_or_default();
3232    // Prefer the git-derived main repo so a linked worktree names its parent
3233    // repository rather than its worktree-folder basename.
3234    let name = status
3235        .main_repo
3236        .clone()
3237        .unwrap_or_else(|| display_name(entry));
3238    if let Some(branch) = &status.branch {
3239        let sep = if status.is_worktree {
3240            WORKTREE_SEP
3241        } else {
3242            REPO_SEP
3243        };
3244        return match sync_indicator(status.ahead, status.behind) {
3245            Some(sync) => format!("{name} {sep} {branch} {sync}"),
3246            None => format!("{name} {sep} {branch}"),
3247        };
3248    }
3249    // No git branch (not a repo / detached): fall back to the reported title.
3250    match &entry.title {
3251        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3252        _ => name,
3253    }
3254}
3255
3256/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
3257/// has no upstream to compare against.
3258fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3259    match (ahead, behind) {
3260        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3261        _ => None,
3262    }
3263}
3264
3265/// Well-known absolute locations for the VS Code launcher, tried in order so a
3266/// daemon running under launchd (with a minimal `PATH`) still finds it.
3267const CODE_BINARY_CANDIDATES: &[&str] = &[
3268    "/usr/local/bin/code",
3269    "/opt/homebrew/bin/code",
3270    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3271    "/usr/bin/code",
3272];
3273
3274/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
3275/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
3276/// with the sessions service's tray "focus" action, which resolves a session to
3277/// its VS Code window folder and opens it through this same guarded launcher.
3278pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3279    focus_window_with(&resolve_code_binary(), folder)
3280}
3281
3282/// Spawns `program` on `folder` after validating the folder. Split out from
3283/// [`focus_window`] so the validation and spawn paths are testable with an
3284/// explicit launcher (no environment or installed-editor dependency).
3285///
3286/// Best-effort and non-blocking: the spawned child is reaped on a detached
3287/// thread so a long-lived daemon does not accumulate zombies one per focus.
3288fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3289    // The tray path passes an absolute workspace folder, but the socket `open`
3290    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
3291    // real check there, not just an assertion: requiring an absolute path also
3292    // rules out a `-`-leading path being parsed by `code` as a flag.
3293    if !folder.is_absolute() {
3294        bail!(
3295            "refusing to focus a non-absolute folder path: {}",
3296            folder.display()
3297        );
3298    }
3299    if !folder.is_dir() {
3300        bail!("worktree folder no longer exists: {}", folder.display());
3301    }
3302    // Detach the launcher's stdio so its output never interleaves into the
3303    // long-lived daemon's own stdout/stderr (or the test harness's).
3304    let child = Command::new(program)
3305        .arg(folder)
3306        .stdin(Stdio::null())
3307        .stdout(Stdio::null())
3308        .stderr(Stdio::null())
3309        .spawn()
3310        .with_context(|| {
3311            format!(
3312                "failed to launch `{}` to focus {}",
3313                program.display(),
3314                folder.display()
3315            )
3316        })?;
3317    // Reap the child without blocking so it never lingers as a zombie.
3318    std::thread::spawn(move || {
3319        let mut child = child;
3320        let _ = child.wait();
3321    });
3322    Ok(())
3323}
3324
3325/// Resolves the VS Code launcher from the real environment: the
3326/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
3327/// `code` on `PATH`. The pure resolution logic lives in
3328/// [`resolve_code_binary_from`] for testing.
3329fn resolve_code_binary() -> PathBuf {
3330    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3331}
3332
3333/// Pure launcher resolution: `env_override` wins; otherwise the first existing
3334/// `candidate`; otherwise bare `code`.
3335fn resolve_code_binary_from(
3336    env_override: Option<std::ffi::OsString>,
3337    candidates: &[&str],
3338) -> PathBuf {
3339    if let Some(path) = env_override {
3340        return PathBuf::from(path);
3341    }
3342    for candidate in candidates {
3343        let path = Path::new(candidate);
3344        if path.exists() {
3345            return path.to_path_buf();
3346        }
3347    }
3348    PathBuf::from("code")
3349}
3350
3351// --- Reposition op (#1407) ---------------------------------------------------
3352
3353/// The `reposition` op payload: move every target window onto the invoking
3354/// window's geometry.
3355///
3356/// Keyed by **window key**, not worktree path, because the subject is a *window*:
3357/// the geometry belongs to the OS window, and a path resolves to one only via the
3358/// registry. The CLI (which naturally speaks paths) maps them to keys itself
3359/// before sending.
3360#[derive(Debug, Clone, Deserialize)]
3361struct RepositionRequest {
3362    /// The invoking window's key. Supplies the frame; never moved itself.
3363    reference_key: String,
3364    /// The windows to move. May include `reference_key` — a multi-selection
3365    /// naturally contains the invoking window — which is reported and skipped.
3366    #[serde(default)]
3367    target_keys: Vec<String>,
3368    /// Resolve and report only, writing nothing (`worktrees reposition
3369    /// --dry-run`). The diagnostic surface for title matching.
3370    #[serde(default)]
3371    check: bool,
3372}
3373
3374/// Distils the live registration for `key` into what [`geometry`] matches on.
3375///
3376/// A key with no live window still yields a value, flagged `live: false`, so it
3377/// reports as a per-target `no-window` skip rather than vanishing from the batch —
3378/// a tree row can be a tick stale, and the user needs to see which of their
3379/// selection was ignored.
3380fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3381    entries.iter().find(|entry| entry.key == key).map_or_else(
3382        || geometry::RegisteredWindow {
3383            key: key.to_string(),
3384            live: false,
3385            title: None,
3386            pid: None,
3387        },
3388        |entry| geometry::RegisteredWindow {
3389            key: entry.key.clone(),
3390            live: true,
3391            title: entry.title.clone(),
3392            pid: entry.pid,
3393        },
3394    )
3395}
3396
3397/// Renders a [`geometry::RepositionReport`] as the op's reply.
3398///
3399/// `trusted` is a reply **field**, not an error, so the client can branch on the
3400/// missing-permission case as data — offering the user a link to the Accessibility
3401/// settings pane — rather than pattern-matching an error string.
3402fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3403    let mut reply = json!({
3404        "trusted": report.trusted,
3405        "results": report.results,
3406        "moved": report.moved(),
3407        "skipped": report.skipped(),
3408    });
3409    if let Some(reference) = &report.reference {
3410        reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3411    }
3412    if let Some(blocked) = &report.blocked {
3413        reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3414    }
3415    // Omitted unless true, so a client that only reads `results` sees a reply
3416    // byte-identical to one from a daemon without the undo store.
3417    if undoable {
3418        reply["undoable"] = Value::Bool(true);
3419    }
3420    reply
3421}
3422
3423/// Emits the audit line for a `reposition`, so `omni-dev daemon logs` can answer
3424/// "why did that window not move?" from the log alone (the ADR-0049 §6 precedent).
3425/// Sync, like [`log_merge_check`].
3426fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3427    // `phase` is a structured field rather than three message literals, so a log
3428    // filter can select checks from applies without matching on prose.
3429    let phase = if !report.trusted {
3430        "untrusted"
3431    } else if report.blocked.is_some() {
3432        "blocked"
3433    } else if req.check {
3434        "check"
3435    } else {
3436        "apply"
3437    };
3438    tracing::info!(
3439        phase,
3440        reference = req.reference_key.as_str(),
3441        requested = req.target_keys.len(),
3442        blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3443        moved = report.moved(),
3444        skipped = report.skipped(),
3445        outcomes = outcome_kinds(report).as_str(),
3446        "reposition"
3447    );
3448}
3449
3450/// Emits the audit line for a `reposition-undo`.
3451fn log_reposition_undo(report: &geometry::RepositionReport) {
3452    tracing::info!(
3453        trusted = report.trusted,
3454        restored = report.moved(),
3455        skipped = report.skipped(),
3456        outcomes = outcome_kinds(report).as_str(),
3457        "reposition undo"
3458    );
3459}
3460
3461/// Joins a report's per-target outcome slugs into one compact `a,b,b` field, so a
3462/// batch's verdict rides a single structured log value rather than a `Debug` dump —
3463/// the [`note_kinds`] precedent.
3464fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3465    if report.results.is_empty() {
3466        return "-".to_string();
3467    }
3468    report
3469        .results
3470        .iter()
3471        .map(|r| r.outcome)
3472        .collect::<Vec<_>>()
3473        .join(",")
3474}
3475
3476// --- Reload op (#1417) -------------------------------------------------------
3477
3478/// The `reload` op payload: reload the listed windows.
3479///
3480/// Keyed by **window**, like [`RepositionRequest`] and unlike [`CloseRequest`] —
3481/// a reload acts on a window, and one tree row is one window, whereas a path can
3482/// be open in several. There is no `requester_key`: a client that wants to
3483/// reload itself does so directly rather than waiting a heartbeat for its own
3484/// directive, so the daemon never needs to know who asked.
3485#[derive(Debug, Clone, Deserialize)]
3486struct ReloadRequest {
3487    /// Registry keys of the windows to signal. An empty list is a no-op, not an
3488    /// error: the callers all filter their targets first, and reporting zeros is
3489    /// more useful to a batch client than a failure.
3490    #[serde(default)]
3491    target_keys: Vec<String>,
3492}
3493
3494/// Emits the audit line for a `reload` op. Sync, like the `close` loggers, so it
3495/// is unit-testable off the runtime. Logs counts and the unknown keys only —
3496/// never a path, which this op never sees.
3497fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3498    // Formatted before the macro, not inside it: a `tracing` field expression is
3499    // only evaluated when a subscriber is interested, so inlining this would
3500    // leave it unexecuted (and unmeasurable) in any test that installs none.
3501    let unknown = if unknown.is_empty() {
3502        "-".to_string()
3503    } else {
3504        unknown.join(",")
3505    };
3506    tracing::info!(
3507        requested,
3508        signalled,
3509        unknown = %unknown,
3510        "worktrees reload: signalled windows"
3511    );
3512}
3513
3514// --- Close op (#1277) --------------------------------------------------------
3515
3516/// The `close` op payload: close a worktree's window and (for a linked worktree)
3517/// delete it. Symmetric to `open`, but destructive, so it carries the
3518/// two-phase-confirm and self-close routing fields.
3519#[derive(Debug, Clone, Deserialize)]
3520struct CloseRequest {
3521    /// Absolute path of the target worktree's working directory.
3522    path: PathBuf,
3523    /// The requesting window's key, so a self-close (`requester_key` owns the
3524    /// target) removes-then-replies and lets the extension close its own window,
3525    /// rather than waiting on a window that is blocked awaiting this reply.
3526    #[serde(default)]
3527    requester_key: Option<String>,
3528    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
3529    /// only close its window (main "Close Window"). A delete is refused on the
3530    /// main working tree regardless of this flag.
3531    #[serde(default)]
3532    remove: bool,
3533    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
3534    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
3535    #[serde(default)]
3536    confirmed: bool,
3537}
3538
3539/// One risk or informational note in a [`SafetyReport`]: a machine-readable
3540/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
3541/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
3542/// commits that survive because the branch is kept).
3543#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3544struct Note {
3545    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
3546    kind: String,
3547    /// A human-readable one-line explanation for the confirm dialog.
3548    detail: String,
3549}
3550
3551impl Note {
3552    fn new(kind: &str, detail: impl Into<String>) -> Self {
3553        Self {
3554            kind: kind.to_string(),
3555            detail: detail.into(),
3556        }
3557    }
3558}
3559
3560/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
3561/// `-`) for a single structured log field. Used by the `close` op's audit lines
3562/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
3563fn note_kinds(notes: &[Note]) -> String {
3564    if notes.is_empty() {
3565        return "-".to_string();
3566    }
3567    notes
3568        .iter()
3569        .map(|n| n.kind.as_str())
3570        .collect::<Vec<_>>()
3571        .join(",")
3572}
3573
3574/// Whether a `close` execute is a **self-close**: the requesting window owns the
3575/// target, so it acts on our `ok:true` reply and never rides the cross-window
3576/// signal. Split out as a pure predicate so the routing decision the audit line
3577/// (#1364) reports is unit-testable.
3578fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3579    requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3580}
3581
3582/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
3583/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
3584/// this a failed safety check (a non-git-worktree target) or a panicked blocking
3585/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
3586/// the error unchanged so callers keep using `?`.
3587fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3588    tracing::error!(
3589        path = %path.display(),
3590        "worktrees close: {phase} failed: {err:#}"
3591    );
3592    err
3593}
3594
3595/// Logs the outcome of a linked-worktree removal and maps it to the `close`
3596/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
3597/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
3598/// the real prune runs behind.
3599///
3600/// The three outcomes are logged distinctly (#1403) so a future "the daemon says
3601/// pruned but the row is still there" is diagnosable from the log alone: an
3602/// actual prune and an already-gone no-op are both INFO (and both reply
3603/// `removed: true` — the row should go either way), but carry different
3604/// `outcome`/message text; a failure is WARN and propagates the error.
3605fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3606    match removed {
3607        Ok(Removal::Pruned) => {
3608            tracing::info!(
3609                path = %path.display(),
3610                outcome = "pruned",
3611                "worktrees close: linked worktree pruned"
3612            );
3613            Ok(json!({ "removed": true }))
3614        }
3615        Ok(Removal::AlreadyGone) => {
3616            tracing::info!(
3617                path = %path.display(),
3618                outcome = "already-gone",
3619                "worktrees close: nothing to prune, worktree already removed"
3620            );
3621            Ok(json!({ "removed": true }))
3622        }
3623        Err(err) => {
3624            tracing::warn!(
3625                path = %path.display(),
3626                outcome = "failed",
3627                "worktrees close: worktree prune failed: {err:#}"
3628            );
3629            Err(err)
3630        }
3631    }
3632}
3633
3634/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
3635/// the owning window key (if any), the open flag, and the deletability verdict
3636/// with the blocking risk kinds. Split out so the audit line is unit-testable off
3637/// the runtime — a `tracing` event fired right after the `git_safety`
3638/// `spawn_blocking` is not reliably captured under the parallel suite.
3639fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3640    tracing::info!(
3641        path = %path.display(),
3642        window_key = window_key.unwrap_or("-"),
3643        removable = git.removable,
3644        is_main = git.is_main,
3645        open,
3646        risks = %note_kinds(&git.risks),
3647        "worktrees close: safety check"
3648    );
3649}
3650
3651/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
3652/// window key and the routing decision (self-close vs. how many cross-window
3653/// targets are being signalled), logged before the wait so it is auditable even
3654/// if that wait then hangs. Sync so it is unit-testable off the runtime.
3655fn log_executing(
3656    path: &Path,
3657    requester: Option<&str>,
3658    remove: bool,
3659    self_close: bool,
3660    cross_window: usize,
3661) {
3662    tracing::info!(
3663        path = %path.display(),
3664        requester = requester.unwrap_or("-"),
3665        remove,
3666        self_close,
3667        cross_window,
3668        "worktrees close: executing"
3669    );
3670}
3671
3672/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
3673/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
3674/// unit-testable off the runtime.
3675fn log_close_abort(path: &Path, err: &anyhow::Error) {
3676    tracing::warn!(
3677        path = %path.display(),
3678        "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3679    );
3680}
3681
3682/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
3683/// the window is closed and nothing is deleted (#1364). Sync so it is
3684/// unit-testable off the runtime.
3685fn log_window_closed(path: &Path) {
3686    tracing::info!(
3687        path = %path.display(),
3688        "worktrees close: window closed, no removal"
3689    );
3690}
3691
3692/// The phase-1 safety report the extension reads to decide whether to prompt.
3693/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
3694/// entry → show a modal confirm listing them.
3695#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3696struct SafetyReport {
3697    /// Whether the target is a deletable (linked) worktree at all — `false` for
3698    /// the main working tree, which the daemon never removes.
3699    removable: bool,
3700    /// Whether the target is the repository's main working tree.
3701    is_main: bool,
3702    /// Whether a live VS Code window currently has the target open.
3703    open: bool,
3704    /// The owning window's key, when `open` (the first, for the wait/close).
3705    #[serde(skip_serializing_if = "Option::is_none")]
3706    window_key: Option<String>,
3707    /// How many workspace folders the owning window has — so the extension can
3708    /// warn "this window has N folders open; all will close" (failure mode #10).
3709    window_folder_count: usize,
3710    /// Conditions that would lose data on removal; a non-empty list forces a
3711    /// confirm dialog.
3712    risks: Vec<Note>,
3713    /// Non-blocking context shown for awareness (e.g. unpushed commits that
3714    /// survive because the branch is kept).
3715    info: Vec<Note>,
3716}
3717
3718/// The git-only half of the safety check, before the registry's open-window
3719/// facts are folded in. Pure disk I/O; computed on a blocking thread.
3720#[derive(Debug, Clone, PartialEq, Eq)]
3721struct GitSafety {
3722    is_main: bool,
3723    removable: bool,
3724    risks: Vec<Note>,
3725    info: Vec<Note>,
3726}
3727
3728// --- Rebase op (#1415) -------------------------------------------------------
3729
3730/// The `rebase` op payload: batch-rebase worktrees onto their repository's remote
3731/// default branch. Two-phase like [`MergeQueueRequest`], keyed off `confirmed`,
3732/// and likewise a **single batched** op over `paths` — which is what buys the
3733/// fetch-once-per-repository contract (ADR-0055 §2), since the engine can only
3734/// group by repository if it sees the whole selection at once.
3735#[derive(Debug, Clone, Deserialize)]
3736struct RebaseRequest {
3737    /// Absolute paths of the selected worktree folders.
3738    paths: Vec<PathBuf>,
3739    /// The requesting window's key — carried for the audit line, as `close` and
3740    /// `merge-queue` carry theirs.
3741    #[serde(default)]
3742    requester_key: Option<String>,
3743    /// Phase 1: plan and report only, never rebase.
3744    #[serde(default)]
3745    check: bool,
3746    /// Phase 2: rebase the (re-validated) pending worktrees.
3747    #[serde(default)]
3748    confirmed: bool,
3749    /// Leave a conflicting worktree mid-rebase instead of aborting it. The tree
3750    /// view sends `true` — resolving a conflict in place is the point of #1415 —
3751    /// but it stays a client choice, and defaults to the engine's conservative
3752    /// abort so an older or scripted client gets the pre-#1415 behaviour.
3753    #[serde(default)]
3754    keep_conflicts: bool,
3755    /// Stash uncommitted changes around each rebase rather than skipping a dirty
3756    /// worktree. Not surfaced by the tree view; here so a socket client can ask.
3757    #[serde(default)]
3758    autostash: bool,
3759    /// Rebase onto this ref instead of the remote default branch.
3760    #[serde(default)]
3761    onto: Option<String>,
3762}
3763
3764impl RebaseRequest {
3765    /// The engine options this request selects, with `git` already resolved.
3766    fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
3767        worktree_rebase::RebaseOptions {
3768            onto: self.onto.clone(),
3769            autostash: self.autostash,
3770            // The daemon never uses the engine's own dry-run flag: phase 1 *is*
3771            // the dry run, and it is `plan` (never `execute`) that runs for it.
3772            dry_run: false,
3773            keep_conflicts: self.keep_conflicts,
3774            git_bin: Some(git_bin),
3775        }
3776    }
3777}
3778
3779/// Runs [`worktree_rebase::plan`] on a blocking thread: it shells out to
3780/// `git fetch` once per repository and walks each worktree's object database, so
3781/// it must never run on an async worker.
3782async fn plan_rebase(
3783    selection: &Selection,
3784    opts: &worktree_rebase::RebaseOptions,
3785) -> Result<worktree_rebase::Plan> {
3786    let selection = selection.clone();
3787    let opts = opts.clone();
3788    tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
3789        .await
3790        .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
3791        .and_then(|inner| inner)
3792}
3793
3794/// Builds a `rebase` reply. Both phases share one shape — the per-repo fetch
3795/// outcomes and the per-worktree results — because phase 1's report and phase 2's
3796/// result differ only in which [`RebaseResult`](worktree_rebase::RebaseResult)
3797/// variants appear, and a client that can render one can render the other.
3798fn rebase_reply(
3799    fetches: &[worktree_rebase::FetchOutcome],
3800    worktrees: &[worktree_rebase::WorktreeOutcome],
3801) -> Value {
3802    json!({ "fetches": fetches, "worktrees": worktrees })
3803}
3804
3805/// Emits the phase-1 audit line for a `rebase` plan (ADR-0049 §6's precedent, as
3806/// applied by [`log_merge_check`]): who asked, how many worktrees were named, and
3807/// how many the classifier found actually pending.
3808fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
3809    let pending = plan
3810        .worktrees
3811        .iter()
3812        .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
3813        .count();
3814    let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
3815    tracing::info!(
3816        requester = req.requester_key.as_deref().unwrap_or("-"),
3817        requested = req.paths.len(),
3818        pending,
3819        fetches = plan.fetches.len(),
3820        failed_fetches,
3821        "rebase check"
3822    );
3823}
3824
3825/// Emits the phase-2 audit line for a `rebase` execute: the history-rewriting
3826/// outcome, counted by kind. A left-in-place conflict is counted separately from
3827/// an aborted one — it is the case that leaves a worktree needing the user.
3828fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
3829    use worktree_rebase::RebaseResult;
3830    let mut rebased = 0;
3831    let mut conflicts = 0;
3832    let mut left_in_place = 0;
3833    let mut skipped = 0;
3834    for outcome in outcomes {
3835        match &outcome.result {
3836            RebaseResult::Rebased { .. } => rebased += 1,
3837            RebaseResult::Conflict {
3838                left_in_place: k, ..
3839            } => {
3840                conflicts += 1;
3841                if *k {
3842                    left_in_place += 1;
3843                }
3844            }
3845            RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
3846            RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
3847        }
3848    }
3849    tracing::info!(
3850        requester = req.requester_key.as_deref().unwrap_or("-"),
3851        requested = req.paths.len(),
3852        rebased,
3853        conflicts,
3854        left_in_place,
3855        skipped,
3856        "rebase execute"
3857    );
3858}
3859
3860// --- Merge-queue op (#1401) --------------------------------------------------
3861
3862/// The `merge-queue` op payload: batch-enqueue eligible worktrees' PRs into the
3863/// GitHub merge queue. Two-phase like [`CloseRequest`], keyed off `confirmed`, but
3864/// a **single batched** op over `paths` rather than one op per target.
3865#[derive(Debug, Clone, Deserialize)]
3866struct MergeQueueRequest {
3867    /// Absolute paths of the selected worktree folders.
3868    paths: Vec<PathBuf>,
3869    /// The requesting window's key — carried for parity with `close` and future
3870    /// per-window routing; unused today.
3871    #[serde(default)]
3872    requester_key: Option<String>,
3873    /// Phase 1: report eligibility only, never enqueue.
3874    #[serde(default)]
3875    check: bool,
3876    /// Phase 2: enqueue the (re-validated) eligible PRs.
3877    #[serde(default)]
3878    confirmed: bool,
3879}
3880
3881/// One enqueue-eligible worktree in an [`EligibilityReport`].
3882#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3883struct PrRef {
3884    /// The worktree folder.
3885    path: String,
3886    /// The open PR number.
3887    number: u64,
3888    /// The PR's web URL.
3889    url: String,
3890    /// The branch the PR heads.
3891    branch: String,
3892}
3893
3894impl From<&Eligible> for PrRef {
3895    fn from(e: &Eligible) -> Self {
3896        Self {
3897            path: e.path.to_string_lossy().to_string(),
3898            number: e.number,
3899            url: e.url.clone(),
3900            branch: e.branch.clone(),
3901        }
3902    }
3903}
3904
3905/// One skipped worktree: which, and why — a machine `kind` slug plus a
3906/// human-readable `detail`, mirroring [`Note`].
3907#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3908struct Skip {
3909    path: String,
3910    kind: String,
3911    detail: String,
3912}
3913
3914impl Skip {
3915    fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
3916        Self {
3917            path: path.to_string_lossy().to_string(),
3918            kind: kind.to_string(),
3919            detail: detail.into(),
3920        }
3921    }
3922}
3923
3924/// The phase-1 reply: which selected worktrees are enqueue-eligible and which are
3925/// skipped-with-reason. The extension confirms once over the whole set, then sends
3926/// the phase-2 execute.
3927#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3928struct EligibilityReport {
3929    eligible: Vec<PrRef>,
3930    skipped: Vec<Skip>,
3931}
3932
3933/// One PR successfully in the queue after phase 2.
3934#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3935struct QueuedPr {
3936    path: String,
3937    number: u64,
3938    /// True when the PR was already in the queue — an idempotent no-op, reported as
3939    /// success. Omitted (false) on the wire for the common freshly-queued case.
3940    #[serde(skip_serializing_if = "is_false")]
3941    already_queued: bool,
3942}
3943
3944/// One PR the enqueue mutation rejected (merge queue disabled, not mergeable,
3945/// insufficient permissions, …).
3946#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3947struct EnqueueFailure {
3948    path: String,
3949    number: u64,
3950    error: String,
3951}
3952
3953/// The phase-2 reply: the enqueue outcome for the selected worktrees. `skipped` is
3954/// the re-validated skip set (a worktree that became ineligible between phases).
3955#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3956struct EnqueueResult {
3957    queued: Vec<QueuedPr>,
3958    skipped: Vec<Skip>,
3959    failed: Vec<EnqueueFailure>,
3960}
3961
3962/// A worktree that cleared the local (git-only) gates 1–3, carrying what the
3963/// network step needs to resolve its PR.
3964#[derive(Debug)]
3965struct LocalOk {
3966    path: PathBuf,
3967    target: PrTarget,
3968    head_sha: String,
3969}
3970
3971/// A worktree that cleared **every** gate and is ready to enqueue.
3972#[derive(Debug)]
3973struct Eligible {
3974    path: PathBuf,
3975    number: u64,
3976    url: String,
3977    branch: String,
3978    /// The PR's GraphQL node id — the `enqueuePullRequest` input.
3979    pr_id: String,
3980    /// Already in the queue ⇒ phase 2 skips the mutation and reports success.
3981    already_queued: bool,
3982}
3983
3984/// Evaluates the **local** (git-only) merge-queue gates for one worktree — clean
3985/// tree (1), a real commit (2), fully pushed (3) — and resolves the branch's
3986/// [`PrTarget`] for the network step. Pure disk I/O; runs on a blocking thread.
3987/// Returns the first failing gate as a [`Skip`] so an ineligible worktree never
3988/// costs a GitHub call.
3989fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
3990    let Ok(repo) = Repository::discover(path) else {
3991        return Err(Skip::new(path, "not-a-repo", "not a git repository"));
3992    };
3993    // Gate 1: a clean working tree (reusing the `close` safety check's counter).
3994    let (dirty, untracked) = count_dirty_untracked(&repo);
3995    if dirty > 0 {
3996        return Err(Skip::new(
3997            path,
3998            "dirty",
3999            format!("{dirty} modified tracked file(s) — commit or stash first"),
4000        ));
4001    }
4002    if untracked > 0 {
4003        return Err(Skip::new(
4004            path,
4005            "untracked",
4006            format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4007        ));
4008    }
4009    // Gate 2: a real commit exists (a non-unborn HEAD). The deeper "commits beyond
4010    // base" is proven by the open PR (gate 4) + GitHub's own enqueue validation.
4011    let Ok(head) = repo.head() else {
4012        return Err(Skip::new(
4013            path,
4014            "no-commits",
4015            "the branch has no commits yet",
4016        ));
4017    };
4018    let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4019        return Err(Skip::new(
4020            path,
4021            "no-commits",
4022            "HEAD does not resolve to a commit",
4023        ));
4024    };
4025    // A branch HEAD has a UTF-8 shorthand; a detached HEAD has no branch — and so
4026    // no branch PR to enqueue. Read before `Branch::wrap` consumes `head`.
4027    let Some(branch_name) = head
4028        .shorthand()
4029        .ok()
4030        .filter(|_| head.is_branch())
4031        .map(str::to_string)
4032    else {
4033        return Err(Skip::new(
4034            path,
4035            "detached",
4036            "HEAD is detached — no branch to enqueue",
4037        ));
4038    };
4039    let branch = git2::Branch::wrap(head);
4040    // Gate 3: fully pushed — an upstream exists and matches the local head.
4041    let Some(upstream_sha) = upstream_target(&branch) else {
4042        return Err(Skip::new(
4043            path,
4044            "no-upstream",
4045            "the branch tracks no upstream — push it first",
4046        ));
4047    };
4048    if upstream_sha != head_sha {
4049        return Err(Skip::new(
4050            path,
4051            "unpushed",
4052            "local commits are not on the remote yet — push first",
4053        ));
4054    }
4055    // Belt-and-suspenders: even with matching heads, a positive ahead count is
4056    // unpushed work.
4057    if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4058        if ahead > 0 {
4059            return Err(Skip::new(
4060                path,
4061                "unpushed",
4062                format!("{ahead} unpushed commit(s) — push first"),
4063            ));
4064        }
4065    }
4066    // Gate 4 setup: the branch's GitHub identity, so the network step can resolve
4067    // its PR. A non-github repo can never have a merge-queue PR.
4068    let Some(id) = remote_github_identity(&repo) else {
4069        return Err(Skip::new(
4070            path,
4071            "no-github",
4072            "the repository has no github.com remote",
4073        ));
4074    };
4075    Ok(LocalOk {
4076        path: path.to_path_buf(),
4077        target: PrTarget {
4078            owner: id.owner,
4079            name: id.name,
4080            branch: branch_name,
4081        },
4082        head_sha,
4083    })
4084}
4085
4086/// Whether GitHub's `mergeStateStatus` says the PR cannot merge cleanly. `DIRTY`
4087/// (merge conflicts) and the explicit `CONFLICTING` both block enqueue; other
4088/// states (`BLOCKED` on a required review, `UNKNOWN` still computing, `CLEAN`) do
4089/// not, since the merge queue itself resolves them.
4090fn is_conflicting(state: Option<&str>) -> bool {
4091    matches!(state, Some("CONFLICTING" | "DIRTY"))
4092}
4093
4094/// A human-readable label for a rolled-up CI verdict, for a `checks-failing` skip.
4095fn check_label(state: PrCheckState) -> &'static str {
4096    match state {
4097        PrCheckState::Success => "passing",
4098        PrCheckState::Failure => "failing",
4099        PrCheckState::Pending => "still running",
4100        PrCheckState::None => "not reported",
4101    }
4102}
4103
4104/// Emits the phase-1 audit line for a `merge-queue` check (ADR-0056; the ADR-0049
4105/// §6 precedent): the requesting window key, how many worktrees were requested,
4106/// and the eligible/skipped split. Sync so it is unit-testable off the runtime —
4107/// and so its `tracing` field expressions are exercised under an INFO subscriber.
4108fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4109    tracing::info!(
4110        requester = req.requester_key.as_deref().unwrap_or("-"),
4111        requested = req.paths.len(),
4112        eligible,
4113        skipped,
4114        "merge-queue check"
4115    );
4116}
4117
4118/// Emits the phase-2 audit line for a `merge-queue` enqueue: the requesting window
4119/// key and the queued/failed/skipped counts. Sync, for the same reasons as
4120/// [`log_merge_check`].
4121fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4122    tracing::info!(
4123        requester = req.requester_key.as_deref().unwrap_or("-"),
4124        queued,
4125        failed,
4126        skipped,
4127        "merge-queue enqueue"
4128    );
4129}
4130
4131/// Evaluates every merge-queue gate for a batch of worktree paths and partitions
4132/// them into the enqueue-eligible and the skipped-with-reason. **Blocking** — run
4133/// on a blocking thread.
4134///
4135/// Local gates 1–3 run first (per path); only survivors reach GitHub, so a dirty
4136/// or unpushed worktree is skipped with **zero** API calls. The survivors' PRs are
4137/// resolved in **one** batched `gh api graphql` call, then the network gates —
4138/// an open PR (4), not a draft (5), not conflicting (6), CI green (7), and the
4139/// remote head matching the local head — are applied. Shared by both phases: phase
4140/// 2 re-runs it (never trusting a phase-1 result the client sent).
4141fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4142    let mut skipped = Vec::new();
4143    let mut locals = Vec::new();
4144    for path in paths {
4145        match evaluate_local(path) {
4146            Ok(ok) => locals.push(ok),
4147            Err(skip) => skipped.push(skip),
4148        }
4149    }
4150    if locals.is_empty() {
4151        return Ok((Vec::new(), skipped));
4152    }
4153    let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4154    let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4155    let mut eligible = Vec::new();
4156    for local in locals {
4157        let Some(info) = resolved.get(&local.target) else {
4158            skipped.push(Skip::new(
4159                &local.path,
4160                "no-pr",
4161                "no open PR heads this branch",
4162            ));
4163            continue;
4164        };
4165        if info.head_oid != local.head_sha {
4166            skipped.push(Skip::new(
4167                &local.path,
4168                "stale",
4169                "the open PR's head differs from the local head — re-check",
4170            ));
4171        } else if info.is_draft {
4172            skipped.push(Skip::new(
4173                &local.path,
4174                "draft",
4175                format!("PR #{} is a draft", info.number),
4176            ));
4177        } else if is_conflicting(info.merge_state.as_deref()) {
4178            skipped.push(Skip::new(
4179                &local.path,
4180                "conflicting",
4181                format!("PR #{} has merge conflicts", info.number),
4182            ));
4183        } else if info.checks != PrCheckState::Success {
4184            skipped.push(Skip::new(
4185                &local.path,
4186                "checks-failing",
4187                format!(
4188                    "PR #{} checks are {}",
4189                    info.number,
4190                    check_label(info.checks)
4191                ),
4192            ));
4193        } else {
4194            eligible.push(Eligible {
4195                path: local.path,
4196                number: info.number,
4197                url: info.url.clone(),
4198                branch: local.target.branch.clone(),
4199                pr_id: info.pr_id.clone(),
4200                already_queued: info.already_queued,
4201            });
4202        }
4203    }
4204    Ok((eligible, skipped))
4205}
4206
4207/// Enqueues each eligible PR into its repo's merge queue, sequentially.
4208/// **Blocking** — run on a blocking thread. An already-queued PR is reported as
4209/// success without a mutation; a GitHub rejection or a failed `gh` invocation
4210/// lands in `failed[]`, so one un-enqueuable PR never sinks the batch.
4211fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4212    let mut queued = Vec::new();
4213    let mut failed = Vec::new();
4214    for e in eligible {
4215        let path = e.path.to_string_lossy().to_string();
4216        if e.already_queued {
4217            queued.push(QueuedPr {
4218                path,
4219                number: e.number,
4220                already_queued: true,
4221            });
4222            continue;
4223        }
4224        match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4225            Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4226                path,
4227                number: e.number,
4228                already_queued: false,
4229            }),
4230            Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4231                path,
4232                number: e.number,
4233                error: msg,
4234            }),
4235            Err(err) => failed.push(EnqueueFailure {
4236                path,
4237                number: e.number,
4238                error: format!("{err:#}"),
4239            }),
4240        }
4241    }
4242    (queued, failed)
4243}
4244
4245/// Live windows (key, workspace-folder count) that currently have `path` open,
4246/// matched by canonicalized path so a symlinked or `..`-laden report still
4247/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
4248fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4249    let target = canonical(path);
4250    entries
4251        .iter()
4252        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4253        .map(|e| (e.key.clone(), e.folders.len()))
4254        .collect()
4255}
4256
4257/// How long the execute phase waits for a signalled window to close
4258/// (`unregister`) before giving up. Deliberately generous against the ~10s
4259/// heartbeat interval the close directive rides — a window may have just
4260/// heartbeated, so the directive is only picked up on the *next* one — plus the
4261/// window's own close/save latency. The keyed-push responsiveness upgrade
4262/// (#1277 fast-follow) removes this wait entirely.
4263const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4264
4265/// How often the execute phase re-checks whether the signalled windows have
4266/// unregistered.
4267const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4268
4269/// Waits up to `timeout` for every window *other than* `requester` that has
4270/// `path` open to unregister (close), polling the live registry every `poll`.
4271/// A window whose `last_seen` has already gone stale is reaped by `list()` and
4272/// so counts as closed. Returns an error naming the still-open windows on
4273/// timeout, so the caller can surface "window did not close" and leave the
4274/// worktree untouched (failure modes #4/#5).
4275async fn await_windows_closed(
4276    registry: &WorktreesRegistry,
4277    path: &Path,
4278    requester: Option<&str>,
4279    timeout: Duration,
4280    poll: Duration,
4281) -> Result<()> {
4282    let deadline = std::time::Instant::now() + timeout;
4283    loop {
4284        // The registry read is cheap CPU, but the path canonicalization in
4285        // `windows_with_path` is disk I/O — do the whole check on a blocking
4286        // thread, never on the async worker.
4287        let entries = registry.list();
4288        let path = path.to_path_buf();
4289        let requester = requester.map(str::to_string);
4290        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4291            windows_with_path(&entries, &path)
4292                .into_iter()
4293                .map(|(k, _)| k)
4294                .filter(|k| requester.as_deref() != Some(k))
4295                .collect()
4296        })
4297        .await
4298        .unwrap_or_default();
4299
4300        if remaining.is_empty() {
4301            return Ok(());
4302        }
4303        if std::time::Instant::now() >= deadline {
4304            bail!("window(s) did not close in time: {}", remaining.join(", "));
4305        }
4306        tokio::time::sleep(poll).await;
4307    }
4308}
4309
4310/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
4311/// working tree (never removable) and, for a linked worktree, what a removal
4312/// would lose. Best-effort per-check but the overall open must succeed — a path
4313/// that is not a git worktree is a hard error (we refuse to delete an unknown
4314/// directory). A path that no longer exists is treated as an already-removed
4315/// linked worktree so the idempotent execute path can proceed with no dialog.
4316fn git_safety(path: &Path) -> Result<GitSafety> {
4317    if !path.exists() {
4318        return Ok(GitSafety {
4319            is_main: false,
4320            removable: true,
4321            risks: vec![],
4322            info: vec![Note::new("already-removed", "worktree no longer exists")],
4323        });
4324    }
4325    let repo = Repository::open(path)
4326        .with_context(|| format!("not a git worktree: {}", path.display()))?;
4327    // The one structural fact deletability keys off — never the branch name.
4328    if !repo.is_worktree() {
4329        return Ok(GitSafety {
4330            is_main: true,
4331            removable: false,
4332            risks: vec![],
4333            info: vec![Note::new(
4334                "main-working-tree",
4335                "the repository's main working tree is never deleted",
4336            )],
4337        });
4338    }
4339
4340    let mut risks = Vec::new();
4341    let mut info = Vec::new();
4342
4343    let (dirty, untracked) = count_dirty_untracked(&repo);
4344    if dirty > 0 {
4345        risks.push(Note::new(
4346            "dirty",
4347            format!("{dirty} modified tracked file(s) would be lost"),
4348        ));
4349    }
4350    if untracked > 0 {
4351        risks.push(Note::new(
4352            "untracked",
4353            format!("{untracked} untracked file(s) would be lost"),
4354        ));
4355    }
4356
4357    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
4358    let state = repo.state();
4359    if state != RepositoryState::Clean {
4360        risks.push(Note::new(
4361            "in-progress",
4362            format!("an in-progress {state:?} operation would be lost"),
4363        ));
4364    }
4365
4366    // Commits reachable only from a detached HEAD are GC'd once the worktree —
4367    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
4368    // or tag) loses nothing, so it is not flagged.
4369    if repo.head_detached().unwrap_or(false) {
4370        let lost = unreachable_commit_count(&repo).unwrap_or(0);
4371        if lost > 0 {
4372            risks.push(Note::new(
4373                "unreachable-commits",
4374                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4375            ));
4376        }
4377    }
4378
4379    // Unpushed commits on a *named* branch survive: removal never deletes the
4380    // branch. Informational only — it must not block or prompt.
4381    if let Some(ahead) = current_branch_ahead(&repo) {
4382        if ahead > 0 {
4383            info.push(Note::new(
4384                "unpushed",
4385                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4386            ));
4387        }
4388    }
4389
4390    Ok(GitSafety {
4391        is_main: false,
4392        removable: true,
4393        risks,
4394        info,
4395    })
4396}
4397
4398/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
4399/// staged or unstaged modification (including conflicts and deletions);
4400/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
4401/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
4402/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
4403/// `(0, 0)` rather than sinking the whole safety check.
4404fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4405    let mut opts = StatusOptions::new();
4406    opts.include_untracked(true)
4407        .recurse_untracked_dirs(true)
4408        .include_ignored(false)
4409        .exclude_submodules(true);
4410    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4411        return (0, 0);
4412    };
4413    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
4414    // it is deliberately excluded from this mask).
4415    let tracked = Status::INDEX_NEW
4416        | Status::INDEX_MODIFIED
4417        | Status::INDEX_DELETED
4418        | Status::INDEX_RENAMED
4419        | Status::INDEX_TYPECHANGE
4420        | Status::WT_MODIFIED
4421        | Status::WT_DELETED
4422        | Status::WT_TYPECHANGE
4423        | Status::WT_RENAMED
4424        | Status::CONFLICTED;
4425    let mut dirty = 0;
4426    let mut untracked = 0;
4427    for entry in statuses.iter() {
4428        let s = entry.status();
4429        if s.contains(Status::WT_NEW) {
4430            untracked += 1;
4431        }
4432        if s.intersects(tracked) {
4433            dirty += 1;
4434        }
4435    }
4436    (dirty, untracked)
4437}
4438
4439/// Counts commits reachable from the (detached) HEAD but from no other ref —
4440/// the commits git would garbage-collect once the worktree's HEAD is gone.
4441/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
4442/// skipped (hiding it would hide the very commits we are counting); every real
4443/// branch/tag/remote ref is hidden, so a tip that any branch also points at
4444/// yields `0` (nothing is actually lost).
4445fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4446    let head_oid = repo.head().ok()?.target()?;
4447    let mut walk = repo.revwalk().ok()?;
4448    walk.push(head_oid).ok()?;
4449    for reference in repo.references().ok()? {
4450        let Ok(reference) = reference else { continue };
4451        // Skip the literal HEAD ref — hiding it would hide the very commits we
4452        // are counting; every real branch/tag/remote ref is hidden below.
4453        if matches!(reference.name(), Ok("HEAD")) {
4454            continue;
4455        }
4456        if let Some(oid) = reference.target() {
4457            let _ = walk.hide(oid);
4458        }
4459    }
4460    Some(walk.flatten().count())
4461}
4462
4463/// Commits the worktree's current branch is ahead of its upstream, or `None`
4464/// when HEAD is detached or the branch tracks no upstream. Reuses
4465/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
4466fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4467    let head = repo.head().ok()?;
4468    if !head.is_branch() {
4469        return None;
4470    }
4471    let branch = git2::Branch::wrap(head);
4472    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4473}
4474
4475/// Resolves the linked worktree whose working directory canonicalizes to
4476/// `target` to its registered name in `main_repo`. Errors when `target` is not
4477/// one of the repo's worktrees — the defensive guard against removing a path
4478/// that opened as a worktree but is not enumerated. Split out so that guard is
4479/// unit-testable without corrupting git's worktree admin state.
4480fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4481    let names = main_repo.worktrees()?;
4482    names
4483        .iter()
4484        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
4485        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
4486        .find(|name| {
4487            main_repo
4488                .find_worktree(name)
4489                .is_ok_and(|wt| canonical(wt.path()) == target)
4490        })
4491        .map(str::to_string)
4492        .ok_or_else(|| {
4493            anyhow!(
4494                "worktree {} is not registered in {}",
4495                target.display(),
4496                main_repo.path().display()
4497            )
4498        })
4499}
4500
4501/// Backoff delays between recursive-removal retries (#1315). A concurrent
4502/// writer — a just-closed window's language server (Metals/Bloop) or
4503/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
4504/// create a file between our directory scan and its `rmdir`, making the removal
4505/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
4506/// waits longer, giving the winding-down process time to quiesce. Total wait
4507/// ~2.75s across four retries; the window teardown the caller already waited on
4508/// dominates it.
4509const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4510    Duration::from_millis(250),
4511    Duration::from_millis(500),
4512    Duration::from_secs(1),
4513    Duration::from_secs(1),
4514];
4515
4516/// Whether `e` is the transient "directory re-populated under us" race we retry
4517/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
4518/// denied, read-only filesystem) we must surface immediately. Matches the raw
4519/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
4520/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
4521fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4522    matches!(
4523        e.raw_os_error(),
4524        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4525    )
4526}
4527
4528/// Recursively removes `dir`, retrying on the transient concurrent-writer race
4529/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
4530/// as success. Non-transient errors surface immediately with the original
4531/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
4532/// `spawn_blocking`), so the between-retry `sleep` is fine.
4533fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4534    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4535}
4536
4537/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
4538/// Provoking the real race requires a concurrent writer to lose a timing window,
4539/// so only an injected sequence of errors can drive every branch of the loop —
4540/// exhausting the backoff especially — deterministically and without sleeping out
4541/// the production schedule.
4542fn remove_dir_all_retrying_with(
4543    dir: &Path,
4544    backoff: &[Duration],
4545    mut remove: impl FnMut() -> std::io::Result<()>,
4546) -> Result<()> {
4547    let mut backoff = backoff.iter();
4548    loop {
4549        match remove() {
4550            Ok(()) => return Ok(()),
4551            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4552            Err(e) => {
4553                if is_transient_rmdir_error(&e) {
4554                    if let Some(delay) = backoff.next() {
4555                        std::thread::sleep(*delay);
4556                        continue;
4557                    }
4558                }
4559                return Err(e).with_context(|| {
4560                    format!("failed to remove worktree directory {}", dir.display())
4561                });
4562            }
4563        }
4564    }
4565}
4566
4567/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
4568/// still points at an admin directory a prior failed removal already deleted.
4569/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
4570/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
4571/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
4572/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
4573/// opens) and a normal checkout has a `.git` *directory*, so this matches
4574/// neither.
4575fn is_orphaned_worktree(path: &Path) -> bool {
4576    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
4577    // a linked worktree's gitlink file gets past here.
4578    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4579        return false;
4580    };
4581    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4582        return false;
4583    };
4584    let admin = Path::new(admin);
4585    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
4586    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4587}
4588
4589/// The outcome of a linked-worktree removal, so the audit log can tell "actually
4590/// removed something" from "nothing was there" (#1403). Before this the
4591/// working-tree-gone-but-admin-present case returned `Ok(())` and logged a
4592/// `pruned` lie, leaving the row stuck in the tree view.
4593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4594enum Removal {
4595    /// The admin metadata (and possibly the working tree) was actually removed.
4596    Pruned,
4597    /// Nothing was there to remove — a truly already-removed worktree.
4598    AlreadyGone,
4599}
4600
4601/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
4602/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
4603/// directory and the admin metadata. Refuses the main working tree (the
4604/// defensive backstop behind the UI gating) and a locked worktree (surfacing
4605/// "unlock first" rather than forcing past the lock). Idempotent: an
4606/// already-removed path is a success.
4607///
4608/// The working tree is removed **first** (retrying to absorb the
4609/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
4610/// This is deliberately the reverse of libgit2's combined
4611/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
4612/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
4613/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
4614/// is already gone). Doing the directory first means a transient failure leaves
4615/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
4616/// the old ordering is detected and its leftover directory cleaned up directly.
4617///
4618/// A working directory that is *already gone* is **not** blindly treated as a
4619/// no-op: a half-removal from outside the daemon (a manual `rm -rf`, an OS
4620/// cleanup) can leave the main repo's `.git/worktrees/<name>/` admin entry behind
4621/// (git marks it `prunable` and the tree view keeps showing the row). That path
4622/// hands off to [`prune_orphaned_admin`], which locates the owning main repo from
4623/// `windows` (or the path's ancestors) and prunes just that entry; only when no
4624/// repo still tracks the path is it reported [`Removal::AlreadyGone`] (#1403).
4625fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4626    if !path.exists() {
4627        return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4628    }
4629    let repo = match Repository::open(path) {
4630        Ok(repo) => repo,
4631        // Admin metadata already gone (a prior failed removal); git no longer
4632        // tracks this path, so no prune applies — just delete the leftover.
4633        Err(_) if is_orphaned_worktree(path) => {
4634            remove_dir_all_retrying(path)?;
4635            return Ok(Removal::Pruned);
4636        }
4637        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4638    };
4639    if !repo.is_worktree() {
4640        bail!(
4641            "refusing to delete the main working tree: {}",
4642            path.display()
4643        );
4644    }
4645    // The Worktree handle lives on the *main* repo (the common dir's parent),
4646    // keyed by name; find it by matching the target path.
4647    let commondir = canonical(repo.commondir());
4648    let main_root = commondir
4649        .parent()
4650        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
4651        .to_path_buf();
4652    // Drop the worktree-scoped handle before we delete its directory.
4653    drop(repo);
4654    let main_repo = Repository::open(&main_root)
4655        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
4656    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
4657    let worktree = main_repo.find_worktree(&name)?;
4658
4659    // Never silently force past a lock (failure mode #6).
4660    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
4661        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
4662        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
4663    }
4664
4665    // Delete the checked-out directory ourselves, retrying past the
4666    // concurrent-writer race (#1315).
4667    remove_dir_all_retrying(path)?;
4668
4669    // The directory is gone; prune only the admin metadata. working_tree(false)
4670    // keeps git2 from re-attempting (and failing on) the now-absent directory;
4671    // valid(true) prunes even though the worktree was valid; locked stays false,
4672    // so a lock (re-checked above) is never forced.
4673    let mut opts = git2::WorktreePruneOptions::new();
4674    opts.valid(true).working_tree(false);
4675    worktree
4676        .prune(Some(&mut opts))
4677        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
4678    Ok(Removal::Pruned)
4679}
4680
4681/// The main-repo roots to search when pruning an orphaned worktree whose working
4682/// directory is already gone (#1403). There is no on-disk breadcrumb from the
4683/// vanished working tree back to its repo — the `.git` gitlink lived *inside* the
4684/// deleted directory — so the owner has to be found by enumerating candidates:
4685///
4686/// - **the path's own ancestors**, covering a worktree nested under its repo
4687///   (e.g. `<repo>/.claude/worktrees/<name>`): an existing ancestor that opens as
4688///   the *main* checkout is the owner. `Repository::open` (not `discover`) so only
4689///   a real repo-root ancestor matches, never an intermediate directory.
4690/// - **every main repo the live `windows` resolve to**, covering an external
4691///   worktree that shares no ancestor with its repo. These are the same repos
4692///   whose `worktrees()` enumeration produced the orphaned row, so this is
4693///   guaranteed to include the owner whenever the UI could show a row to close.
4694///
4695/// Deduped, order-preserving (ancestors first).
4696fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
4697    let mut roots: Vec<PathBuf> = Vec::new();
4698    let mut push = |root: PathBuf| {
4699        if !roots.contains(&root) {
4700            roots.push(root);
4701        }
4702    };
4703    // Skip `path` itself (gone) via `skip(1)`.
4704    for ancestor in path.ancestors().skip(1) {
4705        if let Ok(repo) = Repository::open(ancestor) {
4706            if !repo.is_worktree() {
4707                if let Some(root) = canonical(repo.commondir()).parent() {
4708                    push(root.to_path_buf());
4709                }
4710            }
4711        }
4712    }
4713    for folder in windows.iter().flat_map(|w| &w.folders) {
4714        if let Ok(repo) = Repository::discover(folder) {
4715            if let Some(root) = canonical(repo.commondir()).parent() {
4716                push(root.to_path_buf());
4717            }
4718        }
4719    }
4720    roots
4721}
4722
4723/// Prunes the leftover `.git/worktrees/<name>/` admin metadata of a worktree
4724/// whose working directory is already gone (#1403). Searches
4725/// `candidate_main_repos` for the main repo that still tracks a worktree
4726/// registered at `path`, prunes just that entry's metadata (`working_tree(false)`
4727/// — the checkout is already gone), and returns [`Removal::Pruned`]. When no
4728/// candidate still tracks the path it is truly already-removed:
4729/// [`Removal::AlreadyGone`]. A locked entry is refused, mirroring
4730/// [`remove_worktree`]'s live path, rather than forced past.
4731fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
4732    let target = canonical(path);
4733    for root in candidate_main_repos {
4734        let Ok(main_repo) = Repository::open(root) else {
4735            continue;
4736        };
4737        // Only the main checkout carries the `.git/worktrees/<name>/` admin dir.
4738        if main_repo.is_worktree() {
4739            continue;
4740        }
4741        // Not the owner (or the entry is already pruned) — keep looking.
4742        let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
4743            continue;
4744        };
4745        let worktree = main_repo.find_worktree(&name)?;
4746        if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
4747            let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
4748            bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
4749        }
4750        let mut opts = git2::WorktreePruneOptions::new();
4751        opts.valid(true).working_tree(false);
4752        worktree.prune(Some(&mut opts)).with_context(|| {
4753            format!(
4754                "failed to prune orphaned worktree metadata for {}",
4755                path.display()
4756            )
4757        })?;
4758        return Ok(Removal::Pruned);
4759    }
4760    Ok(Removal::AlreadyGone)
4761}
4762
4763#[cfg(test)]
4764#[allow(clippy::unwrap_used, clippy::expect_used)]
4765mod tests {
4766    use super::*;
4767    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
4768    use std::sync::MutexGuard;
4769
4770    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
4771        json!({
4772            "key": key,
4773            "folders": [folder],
4774            "repo": repo,
4775            "title": format!("{key}-title"),
4776            "pid": 1234,
4777        })
4778    }
4779
4780    /// Pulls the `windows` array out of a `list`/`status` payload.
4781    fn windows_of(payload: &Value) -> &Vec<Value> {
4782        payload
4783            .get("windows")
4784            .and_then(Value::as_array)
4785            .expect("windows array")
4786    }
4787
4788    #[tokio::test]
4789    async fn name_and_unknown_op() {
4790        let svc = WorktreesService::new();
4791        assert_eq!(svc.name(), "worktrees");
4792        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
4793    }
4794
4795    #[tokio::test]
4796    async fn handle_routes_ops_and_shapes_payloads() {
4797        let svc = WorktreesService::new();
4798        // Empty to start.
4799        let payload = svc.handle("list", Value::Null).await.unwrap();
4800        assert_eq!(payload, json!({ "windows": [] }));
4801
4802        // register → { ok: true }, then it shows up in list.
4803        let reply = svc
4804            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
4805            .await
4806            .unwrap();
4807        assert_eq!(reply, json!({ "ok": true }));
4808        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
4809        assert_eq!(windows.len(), 1);
4810        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
4811        assert!(windows[0].get("last_seen").is_some());
4812
4813        // heartbeat known/unknown.
4814        let known = svc
4815            .handle("heartbeat", json!({ "key": "w1" }))
4816            .await
4817            .unwrap();
4818        assert_eq!(known, json!({ "known": true }));
4819        let unknown = svc
4820            .handle("heartbeat", json!({ "key": "nope" }))
4821            .await
4822            .unwrap();
4823        assert_eq!(unknown, json!({ "known": false }));
4824
4825        // reload signals a live window and reports one it does not know.
4826        let reloaded = svc
4827            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
4828            .await
4829            .unwrap();
4830        assert_eq!(
4831            reloaded,
4832            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
4833        );
4834        assert!(svc.registry.take_reload_pending("w1"));
4835
4836        // unregister removes, then repeats as a no-op success.
4837        let gone = svc
4838            .handle("unregister", json!({ "key": "w1" }))
4839            .await
4840            .unwrap();
4841        assert_eq!(gone, json!({ "removed": true }));
4842        let again = svc
4843            .handle("unregister", json!({ "key": "w1" }))
4844            .await
4845            .unwrap();
4846        assert_eq!(again, json!({ "removed": false }));
4847    }
4848
4849    // --- Reposition op (#1407) ------------------------------------------------
4850
4851    /// A window backend over an in-memory window table that **actually applies**
4852    /// writes, so the adapter's own responsibilities — key resolution, the undo
4853    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
4854    /// no Accessibility grant. The planner/matcher itself is covered by
4855    /// `geometry`'s own tests.
4856    ///
4857    /// Applying the writes is what makes a reposition-then-undo round trip mean
4858    /// anything: against a fixed table the restore would find every window already
4859    /// in its wanted position and correctly report `unchanged`.
4860    #[derive(Clone)]
4861    struct StubBackend {
4862        trusted: bool,
4863        /// One application's windows. `Arc` so every clone the factory hands out —
4864        /// and every op in a test — shares the same mutating table.
4865        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
4866        /// Every frame written, in order.
4867        writes: Arc<Mutex<Vec<geometry::Frame>>>,
4868    }
4869
4870    impl StubBackend {
4871        /// One application (pid 900) with two default-format VS Code windows, and
4872        /// two ext-host pids (11, 12) mapping onto it.
4873        fn new(trusted: bool) -> Self {
4874            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
4875                title: title.to_string(),
4876                frame: geometry::Frame {
4877                    x,
4878                    y: 0.0,
4879                    width,
4880                    height: 600.0,
4881                },
4882                minimized: false,
4883                fullscreen: false,
4884                standard: true,
4885                focused: false,
4886            };
4887            Self {
4888                trusted,
4889                windows: Arc::new(Mutex::new(vec![
4890                    window("plan.md — ref-tree", 0.0, 800.0),
4891                    window("main.rs — other-tree", 900.0, 500.0),
4892                ])),
4893                writes: Arc::new(Mutex::new(Vec::new())),
4894            }
4895        }
4896
4897        fn writes(&self) -> Vec<geometry::Frame> {
4898            self.writes
4899                .lock()
4900                .unwrap_or_else(PoisonError::into_inner)
4901                .clone()
4902        }
4903
4904        /// The frame a window currently occupies, after any applied writes.
4905        fn frame_of(&self, index: usize) -> geometry::Frame {
4906            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
4907        }
4908
4909        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
4910        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
4911            let clone = self.clone();
4912            move || clone
4913        }
4914    }
4915
4916    impl geometry::WindowBackend for StubBackend {
4917        fn trusted(&self) -> bool {
4918            self.trusted
4919        }
4920
4921        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
4922            pids.iter()
4923                .filter(|p| **p == 11 || **p == 12)
4924                .map(|p| (*p, 900))
4925                .collect()
4926        }
4927
4928        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
4929            if app_pid != 900 {
4930                return Ok(Vec::new());
4931            }
4932            Ok(self
4933                .windows
4934                .lock()
4935                .unwrap_or_else(PoisonError::into_inner)
4936                .clone())
4937        }
4938
4939        fn set_frame(
4940            &self,
4941            id: geometry::WindowId,
4942            frame: geometry::Frame,
4943        ) -> Result<geometry::Frame, String> {
4944            self.writes
4945                .lock()
4946                .unwrap_or_else(PoisonError::into_inner)
4947                .push(frame);
4948            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
4949            let window = windows
4950                .get_mut(id.index)
4951                .ok_or_else(|| format!("no window at index {}", id.index))?;
4952            window.frame = frame;
4953            Ok(frame)
4954        }
4955    }
4956
4957    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
4958    /// one the stub backend can resolve to an OS window.
4959    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
4960        svc.registry.register(
4961            serde_json::from_value(json!({
4962                "key": key,
4963                "folders": [format!("/tmp/{key}")],
4964                "title": title,
4965                "pid": pid,
4966            }))
4967            .expect("valid register payload"),
4968        );
4969    }
4970
4971    #[tokio::test]
4972    async fn reposition_requires_a_resolvable_reference() {
4973        let svc = WorktreesService::new();
4974        // A missing, blank, or unknown reference key is a hard error: with no
4975        // reference there is no geometry to copy, so the request is meaningless.
4976        assert!(svc.handle("reposition", json!({})).await.is_err());
4977        assert!(svc
4978            .handle("reposition", json!({ "reference_key": "  " }))
4979            .await
4980            .is_err());
4981        assert!(svc
4982            .handle("reposition", json!({ "reference_key": "ghost" }))
4983            .await
4984            .is_err());
4985    }
4986
4987    #[tokio::test]
4988    async fn reposition_moves_targets_and_records_an_undo() {
4989        let svc = WorktreesService::new();
4990        register_window(&svc, "ref", "ref-tree", 11);
4991        register_window(&svc, "other", "other-tree", 12);
4992        let backend = StubBackend::new(true);
4993
4994        let reply = svc
4995            .reposition_with(
4996                serde_json::from_value(json!({
4997                    "reference_key": "ref",
4998                    "target_keys": ["other"],
4999                }))
5000                .unwrap(),
5001                backend.factory(),
5002            )
5003            .await
5004            .unwrap();
5005
5006        assert_eq!(reply["trusted"], json!(true));
5007        assert_eq!(reply["moved"], json!(1));
5008        assert_eq!(reply["skipped"], json!(0));
5009        assert_eq!(reply["undoable"], json!(true));
5010        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5011        assert_eq!(reply["results"][0]["key"], json!("other"));
5012        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5013        // Written the reference's own frame, read from the stub's window table.
5014        assert_eq!(backend.writes().len(), 1);
5015        assert_eq!(
5016            backend.writes()[0],
5017            backend.frame_of(0),
5018            "wrote the reference window's own frame"
5019        );
5020        assert_eq!(
5021            backend.frame_of(1),
5022            backend.frame_of(0),
5023            "the target now occupies the reference's frame"
5024        );
5025
5026        // Undo puts it back where it was — the same backend, so it sees the window
5027        // where the move left it — and consumes the record, so a second undo has
5028        // nothing left to replay onto a layout the user may have since redone.
5029        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5030        assert_eq!(undone["moved"], json!(1));
5031        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5032        assert!(undone.get("reference").is_none(), "undo has no reference");
5033        assert_eq!(
5034            backend.frame_of(1),
5035            geometry::Frame {
5036                x: 900.0,
5037                y: 0.0,
5038                width: 500.0,
5039                height: 600.0,
5040            },
5041            "restored to exactly the pre-move frame"
5042        );
5043
5044        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5045        assert_eq!(
5046            again,
5047            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5048        );
5049    }
5050
5051    #[tokio::test]
5052    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5053        let svc = WorktreesService::new();
5054        register_window(&svc, "ref", "ref-tree", 11);
5055        register_window(&svc, "other", "other-tree", 12);
5056        let backend = StubBackend::new(true);
5057
5058        let reply = svc
5059            .reposition_with(
5060                serde_json::from_value(json!({
5061                    "reference_key": "ref",
5062                    "target_keys": ["other"],
5063                    "check": true,
5064                }))
5065                .unwrap(),
5066                backend.factory(),
5067            )
5068            .await
5069            .unwrap();
5070
5071        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5072        assert!(
5073            reply.get("undoable").is_none(),
5074            "a dry run leaves nothing to undo"
5075        );
5076        assert!(
5077            backend.writes().is_empty(),
5078            "a dry run must not touch a window"
5079        );
5080    }
5081
5082    #[tokio::test]
5083    async fn reposition_reports_a_missing_permission_as_data() {
5084        let svc = WorktreesService::new();
5085        register_window(&svc, "ref", "ref-tree", 11);
5086        register_window(&svc, "other", "other-tree", 12);
5087        let backend = StubBackend::new(false);
5088
5089        let reply = svc
5090            .reposition_with(
5091                serde_json::from_value(json!({
5092                    "reference_key": "ref",
5093                    "target_keys": ["other"],
5094                }))
5095                .unwrap(),
5096                backend.factory(),
5097            )
5098            .await
5099            .unwrap();
5100
5101        // Not an error: the client branches on `trusted` to offer the user a link
5102        // to the Accessibility settings pane.
5103        assert_eq!(reply["trusted"], json!(false));
5104        assert_eq!(reply["moved"], json!(0));
5105        assert!(backend.writes().is_empty());
5106    }
5107
5108    #[tokio::test]
5109    async fn a_stale_target_key_is_skipped_not_fatal() {
5110        let svc = WorktreesService::new();
5111        register_window(&svc, "ref", "ref-tree", 11);
5112        register_window(&svc, "other", "other-tree", 12);
5113        let backend = StubBackend::new(true);
5114
5115        let reply = svc
5116            .reposition_with(
5117                serde_json::from_value(json!({
5118                    "reference_key": "ref",
5119                    // A window that closed since the tree row was rendered, the
5120                    // reference itself, and a real target.
5121                    "target_keys": ["closed-since", "ref", "other"],
5122                }))
5123                .unwrap(),
5124                backend.factory(),
5125            )
5126            .await
5127            .unwrap();
5128
5129        let outcomes: Vec<&str> = reply["results"]
5130            .as_array()
5131            .unwrap()
5132            .iter()
5133            .map(|r| r["outcome"].as_str().unwrap())
5134            .collect();
5135        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5136        assert_eq!(reply["moved"], json!(1));
5137        assert_eq!(reply["skipped"], json!(2));
5138    }
5139
5140    #[tokio::test]
5141    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5142        let svc = WorktreesService::new();
5143        // Two windows share a root name, so the *reference* cannot be resolved and
5144        // the whole batch is refused before any target is attempted.
5145        register_window(&svc, "ref", "twin", 11);
5146        register_window(&svc, "other", "other-tree", 12);
5147        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5148        // `mut` binding — and the same shared table backs the factory's clone.
5149        let backend = StubBackend::new(true);
5150        {
5151            let mut windows = backend
5152                .windows
5153                .lock()
5154                .unwrap_or_else(PoisonError::into_inner);
5155            windows[0].title = "a.rs — twin".to_string();
5156            windows[1].title = "b.rs — twin".to_string();
5157        }
5158
5159        let reply = svc
5160            .reposition_with(
5161                serde_json::from_value(json!({
5162                    "reference_key": "ref",
5163                    "target_keys": ["other"],
5164                }))
5165                .unwrap(),
5166                backend.factory(),
5167            )
5168            .await
5169            .unwrap();
5170
5171        assert_eq!(reply["trusted"], json!(true));
5172        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5173        assert!(
5174            reply["blocked"]["detail"]
5175                .as_str()
5176                .is_some_and(|d| d.contains("twin")),
5177            "the reason should name the ambiguous title: {reply}"
5178        );
5179        assert_eq!(reply["results"], json!([]), "no target is attempted");
5180        assert!(reply.get("undoable").is_none());
5181        assert!(backend.writes().is_empty());
5182
5183        // And nothing was recorded, so a following undo has nothing to replay.
5184        let undone = svc
5185            .reposition_undo_with(StubBackend::new(true).factory())
5186            .await
5187            .unwrap();
5188        assert_eq!(undone["moved"], json!(0));
5189    }
5190
5191    #[tokio::test]
5192    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5193        let svc = WorktreesService::new();
5194        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5195        assert_eq!(reply["moved"], json!(0));
5196        assert_eq!(reply["results"], json!([]));
5197    }
5198
5199    #[test]
5200    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5201        let empty = geometry::RepositionReport {
5202            trusted: true,
5203            blocked: None,
5204            reference: None,
5205            results: Vec::new(),
5206            undo: Vec::new(),
5207        };
5208        assert_eq!(outcome_kinds(&empty), "-");
5209    }
5210
5211    #[tokio::test]
5212    async fn handle_rejects_missing_or_empty_key() {
5213        let svc = WorktreesService::new();
5214        // register validates a present, non-blank key.
5215        assert!(svc.handle("register", json!({})).await.is_err());
5216        assert!(svc
5217            .handle("register", json!({ "key": "  " }))
5218            .await
5219            .is_err());
5220        // heartbeat/unregister require the key via `require_str`.
5221        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5222        assert!(svc.handle("unregister", json!({})).await.is_err());
5223    }
5224
5225    #[test]
5226    fn display_name_prefers_repo_then_folder_basename() {
5227        let base = WindowEntry {
5228            key: "k".to_string(),
5229            folders: vec![PathBuf::from("/home/me/project")],
5230            repo: Some("my-repo".to_string()),
5231            title: None,
5232            pid: None,
5233            last_seen: Utc::now(),
5234        };
5235        assert_eq!(display_name(&base), "my-repo");
5236
5237        let no_repo = WindowEntry {
5238            repo: None,
5239            ..base.clone()
5240        };
5241        assert_eq!(display_name(&no_repo), "project");
5242
5243        let nothing = WindowEntry {
5244            repo: None,
5245            folders: vec![],
5246            ..base.clone()
5247        };
5248        assert_eq!(display_name(&nothing), "(no folder)");
5249
5250        // A folder with no basename (the filesystem root) falls back to its
5251        // displayed path rather than panicking or yielding an empty name.
5252        let rootish = WindowEntry {
5253            repo: None,
5254            folders: vec![PathBuf::from("/")],
5255            ..base
5256        };
5257        assert_eq!(display_name(&rootish), "/");
5258    }
5259
5260    #[test]
5261    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5262        let now = Utc::now();
5263        let entries = vec![
5264            // A folderless window has nothing to focus, so it stays a plain
5265            // Label; a title equal to the name collapses to just the name. It
5266            // leads the list so the focus-action lookup below is exercised
5267            // against a leading non-Action item it has to skip.
5268            WindowEntry {
5269                key: "k2".to_string(),
5270                folders: vec![],
5271                repo: Some("solo".to_string()),
5272                title: Some("solo".to_string()),
5273                pid: None,
5274                last_seen: now,
5275            },
5276            // A folder-bearing, non-repo window: one clickable Action whose label
5277            // is the stats line ("name · title", since /tmp is not a git repo).
5278            WindowEntry {
5279                key: "k1".to_string(),
5280                folders: vec![PathBuf::from("/tmp/a")],
5281                repo: Some("repo".to_string()),
5282                title: Some("a branch".to_string()),
5283                pid: None,
5284                last_seen: now,
5285            },
5286        ];
5287        let items = window_menu_items(&entries);
5288        // Exactly one item per window — no duplicate label, no separator.
5289        assert_eq!(items.len(), 2);
5290        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5291
5292        // The folder-bearing window is a single clickable action carrying the
5293        // stats label (the old label + Focus action, merged).
5294        let action = items
5295            .iter()
5296            .find_map(|i| match i {
5297                MenuItem::Action(a) => Some(a),
5298                _ => None,
5299            })
5300            .expect("a focus action");
5301        assert_eq!(action.id, "focus:k1");
5302        assert_eq!(action.label, "repo · a branch");
5303
5304        // The folderless window is a non-clickable label (not "solo · solo").
5305        let labels: Vec<&str> = items
5306            .iter()
5307            .filter_map(|i| match i {
5308                MenuItem::Label(t) => Some(t.as_str()),
5309                _ => None,
5310            })
5311            .collect();
5312        assert_eq!(labels, vec!["solo"]);
5313    }
5314
5315    #[tokio::test]
5316    async fn menu_and_status_shapes() {
5317        let svc = WorktreesService::new();
5318        // Empty.
5319        let menu = svc.menu();
5320        assert_eq!(menu.title, "Worktrees");
5321        assert!(matches!(
5322            menu.items.first(),
5323            Some(MenuItem::Label(text)) if text == "No open windows"
5324        ));
5325        let status = svc.status().await;
5326        assert_eq!(status.name, "worktrees");
5327        assert!(status.healthy);
5328        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5329
5330        // Two folder-bearing windows in the same repo, plus one folderless
5331        // window that shares the repo but has nothing for `code` to open.
5332        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5333            .await
5334            .unwrap();
5335        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5336            .await
5337            .unwrap();
5338        svc.handle(
5339            "register",
5340            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5341        )
5342        .await
5343        .unwrap();
5344        let status = svc.status().await;
5345        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5346
5347        let menu = svc.menu();
5348        // One line per window — no separator, no duplicate label.
5349        assert_eq!(menu.items.len(), 3);
5350        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5351        let action_ids: Vec<&str> = menu
5352            .items
5353            .iter()
5354            .filter_map(|i| match i {
5355                MenuItem::Action(a) => Some(a.id.as_str()),
5356                _ => None,
5357            })
5358            .collect();
5359        // The two folder-bearing windows are clickable; the folderless one is a
5360        // plain Label, so it never yields a focus action.
5361        assert!(action_ids.contains(&"focus:w1"));
5362        assert!(action_ids.contains(&"focus:w2"));
5363        assert!(!action_ids.contains(&"focus:w3"));
5364    }
5365
5366    #[test]
5367    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5368        // With no tokio runtime, the background task is never spawned, so the
5369        // bare service keeps computing `menu()` inline (what the tests rely on).
5370        let svc = WorktreesService::new();
5371        svc.start_menu_refresh();
5372        assert!(svc.refresh.lock().unwrap().is_none());
5373    }
5374
5375    #[tokio::test]
5376    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5377        let svc = WorktreesService::new();
5378        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5379            .await
5380            .unwrap();
5381        // Before the task runs, `menu()` computes inline from an empty cache.
5382        assert!(svc.menu_cache.lock().unwrap().is_none());
5383
5384        svc.start_menu_refresh();
5385        // Idempotent: a second call does not start a second task.
5386        svc.start_menu_refresh();
5387
5388        // The task fills the cache off the main thread; poll briefly for it.
5389        let mut filled = false;
5390        for _ in 0..100 {
5391            if svc.menu_cache.lock().unwrap().is_some() {
5392                filled = true;
5393                break;
5394            }
5395            tokio::time::sleep(Duration::from_millis(10)).await;
5396        }
5397        assert!(filled, "background refresh should populate the menu cache");
5398
5399        // `menu()` now serves the cache: one clickable line for the window.
5400        let menu = svc.menu();
5401        assert_eq!(menu.title, "Worktrees");
5402        assert!(menu
5403            .items
5404            .iter()
5405            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5406
5407        // Shutdown cancels and joins the task, clearing the handle.
5408        svc.shutdown().await;
5409        assert!(svc.refresh.lock().unwrap().is_none());
5410    }
5411
5412    #[tokio::test]
5413    async fn default_constructs_an_empty_service() {
5414        let svc = WorktreesService::default();
5415        let payload = svc.handle("list", Value::Null).await.unwrap();
5416        assert_eq!(payload, json!({ "windows": [] }));
5417    }
5418
5419    // --- Push subscription (#1267) -----------------------------------------
5420
5421    #[tokio::test]
5422    async fn subscribe_streams_only_for_the_subscribe_op() {
5423        let svc = WorktreesService::new();
5424        // The one streaming op yields a stream; every other op (including the
5425        // request/reply worktrees ops) declines, so the server dispatches them
5426        // normally.
5427        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5428        assert!(svc.subscribe("list", &Value::Null).is_none());
5429        assert!(svc.subscribe("register", &Value::Null).is_none());
5430        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5431    }
5432
5433    #[tokio::test]
5434    async fn subscribe_snapshot_matches_the_tree_op() {
5435        let dir = tempfile::tempdir().unwrap();
5436        let repo = init_repo(dir.path());
5437        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5438        repo.set_head("refs/heads/main").unwrap();
5439
5440        let svc = WorktreesService::new();
5441        let stream = svc
5442            .subscribe("subscribe", &Value::Null)
5443            .expect("subscribe stream");
5444        // No windows yet → no repos derived; the toggle rides along at its
5445        // default (show all).
5446        assert_eq!(
5447            stream.snapshot().await,
5448            json!({ "repos": [], "show_closed": true })
5449        );
5450
5451        // A window opens on the repo → the snapshot carries it, byte-identical to
5452        // what the `tree` op returns for the same registry state.
5453        svc.handle(
5454            "register",
5455            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5456        )
5457        .await
5458        .unwrap();
5459        let snap = stream.snapshot().await;
5460        let tree = svc.handle("tree", Value::Null).await.unwrap();
5461        assert_eq!(snap, tree);
5462        let repos = snap["repos"].as_array().expect("repos array");
5463        assert_eq!(repos.len(), 1);
5464        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5465    }
5466
5467    #[tokio::test]
5468    async fn subscribe_changed_wakes_on_register() {
5469        let svc = WorktreesService::new();
5470        let mut stream = svc
5471            .subscribe("subscribe", &Value::Null)
5472            .expect("subscribe stream");
5473        // Idle: `changed()` must not resolve without a registry change.
5474        tokio::select! {
5475            () = stream.changed() => panic!("changed resolved with no registry change"),
5476            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5477        }
5478        // A register bumps the change-notify → `changed()` resolves promptly.
5479        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5480            .await
5481            .unwrap();
5482        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5483            .await
5484            .expect("changed should resolve after a register");
5485    }
5486
5487    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5488
5489    #[tokio::test]
5490    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5491        let reg = Arc::new(WorktreesRegistry::new());
5492        // A long TTL so only the generation gate is exercised here.
5493        let cache = TreeSnapshotCache::with_ttl(
5494            reg,
5495            Arc::new(PrStatusCache::new()),
5496            Duration::from_secs(60),
5497        );
5498        // The first read builds once.
5499        let first = cache.snapshot().await;
5500        assert_eq!(cache.compute_count(), 1);
5501        // Further reads with no registry change and within the TTL reuse the
5502        // cached value — no extra build, byte-identical result.
5503        let second = cache.snapshot().await;
5504        assert_eq!(
5505            cache.compute_count(),
5506            1,
5507            "an unchanged read must not rebuild"
5508        );
5509        assert_eq!(first, second);
5510    }
5511
5512    #[tokio::test]
5513    async fn tree_cache_single_flights_a_read_burst() {
5514        let reg = Arc::new(WorktreesRegistry::new());
5515        let cache = Arc::new(TreeSnapshotCache::with_ttl(
5516            reg,
5517            Arc::new(PrStatusCache::new()),
5518            Duration::from_secs(60),
5519        ));
5520        // A burst of concurrent readers — as N subscriber streams would wake
5521        // together on a change/tick — collapses to exactly one build; the rest
5522        // read the shared result (the acceptance criterion).
5523        let mut handles = Vec::new();
5524        for _ in 0..16 {
5525            let cache = cache.clone();
5526            handles.push(tokio::spawn(async move { cache.snapshot().await }));
5527        }
5528        let mut results = Vec::new();
5529        for handle in handles {
5530            results.push(handle.await.unwrap());
5531        }
5532        assert_eq!(
5533            cache.compute_count(),
5534            1,
5535            "a concurrent read burst must build the tree once"
5536        );
5537        assert!(
5538            results.windows(2).all(|w| w[0] == w[1]),
5539            "every reader must observe the identical snapshot"
5540        );
5541    }
5542
5543    #[tokio::test]
5544    async fn tree_cache_rebuilds_on_registry_change() {
5545        let reg = Arc::new(WorktreesRegistry::new());
5546        let cache = TreeSnapshotCache::with_ttl(
5547            reg.clone(),
5548            Arc::new(PrStatusCache::new()),
5549            Duration::from_secs(60),
5550        );
5551        cache.snapshot().await;
5552        assert_eq!(cache.compute_count(), 1);
5553        // A registry change bumps the generation, so the next read rebuilds even
5554        // though the (long) TTL has not expired — subscribers never see a stale
5555        // visible set.
5556        assert!(reg.set_show_closed(false));
5557        cache.snapshot().await;
5558        assert_eq!(
5559            cache.compute_count(),
5560            2,
5561            "a generation bump must force a rebuild"
5562        );
5563    }
5564
5565    #[tokio::test]
5566    async fn tree_cache_rebuilds_after_ttl_expiry() {
5567        let reg = Arc::new(WorktreesRegistry::new());
5568        // A zero TTL: every read is already past it, so a pure on-disk git change
5569        // still surfaces on the next tick with no registry bump needed.
5570        let cache =
5571            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5572        cache.snapshot().await;
5573        cache.snapshot().await;
5574        assert_eq!(
5575            cache.compute_count(),
5576            2,
5577            "an expired TTL must force a rebuild each read"
5578        );
5579    }
5580
5581    #[tokio::test]
5582    async fn subscribe_streams_share_one_build_per_generation() {
5583        let svc = WorktreesService::new();
5584        let s1 = svc
5585            .subscribe("subscribe", &Value::Null)
5586            .expect("subscribe stream");
5587        let s2 = svc
5588            .subscribe("subscribe", &Value::Null)
5589            .expect("subscribe stream");
5590        // Two windows' streams sampling the same registry state build the tree
5591        // once, not once per stream (#1303) — they share the service's cache.
5592        let a = s1.snapshot().await;
5593        let b = s2.snapshot().await;
5594        assert_eq!(a, b);
5595        assert_eq!(
5596            svc.tree_cache.compute_count(),
5597            1,
5598            "N streams on one generation must share a single build"
5599        );
5600    }
5601
5602    // --- Show/hide-closed toggle (#1301) -----------------------------------
5603
5604    #[tokio::test]
5605    async fn set_show_closed_toggles_the_snapshot_field() {
5606        let svc = WorktreesService::new();
5607        // The snapshot carries the toggle; it defaults to show-all.
5608        assert_eq!(
5609            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5610            json!(true)
5611        );
5612        // Setting it flips the field the next snapshot reports.
5613        let reply = svc
5614            .handle("set-show-closed", json!({ "show_closed": false }))
5615            .await
5616            .unwrap();
5617        assert_eq!(reply, json!({ "ok": true }));
5618        assert_eq!(
5619            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5620            json!(false)
5621        );
5622    }
5623
5624    #[tokio::test]
5625    async fn set_show_closed_rejects_a_non_boolean_payload() {
5626        let svc = WorktreesService::new();
5627        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5628        assert!(svc
5629            .handle("set-show-closed", json!({ "show_closed": "yes" }))
5630            .await
5631            .is_err());
5632    }
5633
5634    #[tokio::test]
5635    async fn set_show_closed_wakes_the_subscription() {
5636        let svc = WorktreesService::new();
5637        let mut stream = svc
5638            .subscribe("subscribe", &Value::Null)
5639            .expect("subscribe stream");
5640        // A real flip bumps the change-notify → `changed()` resolves promptly.
5641        svc.handle("set-show-closed", json!({ "show_closed": false }))
5642            .await
5643            .unwrap();
5644        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5645            .await
5646            .expect("changed should resolve after a toggle flip");
5647        // The pushed snapshot now reflects the new toggle.
5648        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
5649    }
5650
5651    #[tokio::test]
5652    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
5653        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
5654        // drops it (skip-if-false), so the extension colours the icon off the flag.
5655        let dir = tempfile::tempdir().unwrap();
5656        github_repo(dir.path());
5657        let svc = WorktreesService::new();
5658        svc.handle(
5659            "register",
5660            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5661        )
5662        .await
5663        .unwrap();
5664
5665        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5666        assert!(
5667            repo.get("polling_enabled").is_none(),
5668            "default off omits the flag: {repo:?}"
5669        );
5670
5671        let reply = svc
5672            .handle(
5673                "set-polling",
5674                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5675            )
5676            .await
5677            .unwrap();
5678        assert_eq!(reply, json!({ "ok": true }));
5679        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5680        assert_eq!(repo["polling_enabled"], json!(true));
5681
5682        svc.handle(
5683            "set-polling",
5684            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5685        )
5686        .await
5687        .unwrap();
5688        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
5689        assert!(repo.get("polling_enabled").is_none());
5690    }
5691
5692    #[tokio::test]
5693    async fn set_polling_rejects_missing_or_empty_fields() {
5694        let svc = WorktreesService::new();
5695        // Missing `enabled`.
5696        assert!(svc
5697            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
5698            .await
5699            .is_err());
5700        // Missing `owner`/`name`.
5701        assert!(svc
5702            .handle("set-polling", json!({ "enabled": true }))
5703            .await
5704            .is_err());
5705        // Blank `owner`/`name`.
5706        assert!(svc
5707            .handle(
5708                "set-polling",
5709                json!({ "owner": " ", "name": "n", "enabled": true })
5710            )
5711            .await
5712            .is_err());
5713    }
5714
5715    #[tokio::test]
5716    async fn set_polling_wakes_the_subscription() {
5717        let dir = tempfile::tempdir().unwrap();
5718        github_repo(dir.path());
5719        let svc = WorktreesService::new();
5720        svc.handle(
5721            "register",
5722            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5723        )
5724        .await
5725        .unwrap();
5726        let mut stream = svc
5727            .subscribe("subscribe", &Value::Null)
5728            .expect("subscribe stream");
5729        svc.handle(
5730            "set-polling",
5731            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5732        )
5733        .await
5734        .unwrap();
5735        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5736            .await
5737            .expect("changed should resolve after enabling a repo");
5738        let repo = repos_of(&stream.snapshot().await)[0].clone();
5739        assert_eq!(repo["polling_enabled"], json!(true));
5740    }
5741
5742    #[tokio::test]
5743    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
5744        // The "drop existing badges immediately" requirement (#1376), done
5745        // daemon-side: the fold skips a not-polled repo, so a disable strips the
5746        // badge on the very next snapshot rather than waiting for a poll.
5747        let dir = tempfile::tempdir().unwrap();
5748        let repo = github_repo(dir.path());
5749        let head = repo.head().unwrap().target().unwrap().to_string();
5750        let svc = WorktreesService::new();
5751        svc.handle(
5752            "register",
5753            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5754        )
5755        .await
5756        .unwrap();
5757        svc.registry.set_polling("rust-works", "omni-dev", true);
5758
5759        let mut badges = HashMap::new();
5760        badges.insert(
5761            PrTarget {
5762                owner: "rust-works".into(),
5763                name: "omni-dev".into(),
5764                branch: "main".into(),
5765            },
5766            pr(pending_badge(7, &head)),
5767        );
5768        svc.pr_cache.replace(badges);
5769
5770        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5771        assert_eq!(wt["pr"]["number"], json!(7));
5772
5773        svc.handle(
5774            "set-polling",
5775            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5776        )
5777        .await
5778        .unwrap();
5779        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5780        assert!(
5781            wt.get("pr").is_none(),
5782            "a disabled repo must carry no badge: {wt:?}"
5783        );
5784    }
5785
5786    #[tokio::test]
5787    async fn an_expired_lease_drops_the_flag_and_badges() {
5788        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
5789        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
5790        // poller would no longer watch it — all reaped on read, no timer.
5791        let dir = tempfile::tempdir().unwrap();
5792        let repo = github_repo(dir.path());
5793        let head = repo.head().unwrap().target().unwrap().to_string();
5794        let svc = WorktreesService::new();
5795        svc.handle(
5796            "register",
5797            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5798        )
5799        .await
5800        .unwrap();
5801        svc.registry.set_polling("rust-works", "omni-dev", true);
5802        let mut badges = HashMap::new();
5803        badges.insert(
5804            PrTarget {
5805                owner: "rust-works".into(),
5806                name: "omni-dev".into(),
5807                branch: "main".into(),
5808            },
5809            pr(pending_badge(7, &head)),
5810        );
5811        svc.pr_cache.replace(badges);
5812
5813        // Leased: flag stamped, badge folded, and the poller would watch it.
5814        let snap = svc.handle("tree", Value::Null).await.unwrap();
5815        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
5816        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
5817        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
5818
5819        // Force the lease into the past — as 15 minutes elapsing would.
5820        svc.registry.set_polling_expiry(
5821            "rust-works",
5822            "omni-dev",
5823            Utc::now() - chrono::Duration::minutes(1),
5824        );
5825
5826        let snap = svc.handle("tree", Value::Null).await.unwrap();
5827        let repo0 = &repos_of(&snap)[0];
5828        assert!(
5829            repo0.get("polling_enabled").is_none(),
5830            "expired lease drops the flag: {repo0:?}"
5831        );
5832        assert!(
5833            repo0["worktrees"][0].get("pr").is_none(),
5834            "expired lease drops the badge"
5835        );
5836        assert!(
5837            pr_targets_from_snapshot(&snap).is_empty(),
5838            "the poller no longer watches an expired repo"
5839        );
5840    }
5841
5842    #[tokio::test]
5843    async fn polling_prefs_persist_across_reloads_with_0600() {
5844        // The enable set survives a daemon restart (#1376): a change writes the
5845        // `0600` file, and a fresh service seeded from it comes up enabled.
5846        let dir = tempfile::tempdir().unwrap();
5847        let prefs = dir.path().join("worktrees-polling.json");
5848
5849        let svc = WorktreesService::new();
5850        svc.load_polling_prefs(prefs.clone());
5851        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
5852        svc.handle(
5853            "set-polling",
5854            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
5855        )
5856        .await
5857        .unwrap();
5858        assert!(prefs.exists());
5859        #[cfg(unix)]
5860        {
5861            use std::os::unix::fs::PermissionsExt;
5862            assert_eq!(
5863                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
5864                0o600
5865            );
5866        }
5867
5868        // A new service reloads the enabled set from that file.
5869        let svc2 = WorktreesService::new();
5870        svc2.load_polling_prefs(prefs.clone());
5871        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
5872
5873        // Disabling rewrites the file, so the next reload has nothing enabled.
5874        svc2.handle(
5875            "set-polling",
5876            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
5877        )
5878        .await
5879        .unwrap();
5880        let svc3 = WorktreesService::new();
5881        svc3.load_polling_prefs(prefs);
5882        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
5883    }
5884
5885    #[test]
5886    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
5887        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
5888        // path is logged and treated as "nothing enabled" rather than wedging the
5889        // service. A missing file is already the first-run default (covered by the
5890        // persistence round-trip above); this exercises the two error branches.
5891        let dir = tempfile::tempdir().unwrap();
5892
5893        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
5894        let corrupt = dir.path().join("worktrees-polling.json");
5895        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
5896        let svc = WorktreesService::new();
5897        svc.load_polling_prefs(corrupt);
5898        assert!(svc.registry.enabled_polling_repos().is_empty());
5899
5900        // A directory at the path — the (non-NotFound) read error is swallowed too.
5901        let as_dir = dir.path().join("is-a-directory");
5902        std::fs::create_dir(&as_dir).unwrap();
5903        let svc2 = WorktreesService::new();
5904        svc2.load_polling_prefs(as_dir);
5905        assert!(svc2.registry.enabled_polling_repos().is_empty());
5906    }
5907
5908    #[tokio::test]
5909    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
5910        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
5911        // the user has not enabled polling for it — so the poller spawns no `gh`.
5912        let dir = tempfile::tempdir().unwrap();
5913        github_repo(dir.path());
5914        let bin_dir = tempfile::tempdir().unwrap();
5915        let marker = bin_dir.path().join("spawned");
5916        let fake = bin_dir.path().join("fake-gh");
5917        std::fs::write(
5918            &fake,
5919            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
5920        )
5921        .unwrap();
5922        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5923        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5924        std::fs::set_permissions(&fake, perms).unwrap();
5925
5926        let svc = WorktreesService::new();
5927        svc.handle(
5928            "register",
5929            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5930        )
5931        .await
5932        .unwrap();
5933        // Deliberately NOT enabling polling for the repo.
5934        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5935        tokio::time::sleep(Duration::from_millis(200)).await;
5936        svc.shutdown().await;
5937        assert!(
5938            !marker.exists(),
5939            "a registered-but-not-enabled repo must drive zero gh"
5940        );
5941    }
5942
5943    #[tokio::test]
5944    async fn menu_action_rejects_unknown_and_missing_window() {
5945        let svc = WorktreesService::new();
5946        assert!(svc.menu_action("bogus").await.is_err());
5947        // A focus for a key with no registration errors rather than spawning.
5948        assert!(svc.menu_action("focus:nope").await.is_err());
5949        svc.shutdown().await;
5950    }
5951
5952    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
5953    /// variable (via `resolve_code_binary` → `focus_window`) —
5954    /// `menu_action_focus_resolves_folder_and_spawns` and
5955    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
5956    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
5957    /// transient overlap under the harness's test parallelism is benign.
5958    struct VscodeBinGuard(Option<std::ffi::OsString>);
5959    impl Drop for VscodeBinGuard {
5960        fn drop(&mut self) {
5961            match self.0.take() {
5962                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
5963                None => std::env::remove_var(VSCODE_BIN_ENV),
5964            }
5965        }
5966    }
5967
5968    #[tokio::test]
5969    async fn menu_action_focus_resolves_folder_and_spawns() {
5970        let dir = tempfile::tempdir().unwrap();
5971        let svc = WorktreesService::new();
5972        svc.handle(
5973            "register",
5974            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5975        )
5976        .await
5977        .unwrap();
5978
5979        // Point the launcher at a harmless binary so the spawn deterministically
5980        // succeeds and the focus path returns Ok.
5981        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
5982        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
5983        svc.menu_action("focus:w1").await.unwrap();
5984    }
5985
5986    #[tokio::test]
5987    async fn open_rejects_missing_relative_or_nonexistent_path() {
5988        let svc = WorktreesService::new();
5989        // A missing `path` is a payload error.
5990        assert!(svc.handle("open", json!({})).await.is_err());
5991        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
5992        // A relative path is rejected before any spawn — this is also what
5993        // blocks a `-`-leading argument from reaching `code` as a flag.
5994        assert!(svc
5995            .handle("open", json!({ "path": "relative/dir" }))
5996            .await
5997            .is_err());
5998        assert!(svc
5999            .handle("open", json!({ "path": "-flag" }))
6000            .await
6001            .is_err());
6002        // An absolute path that does not exist is rejected before any spawn, so
6003        // no launcher is needed for these guard cases.
6004        assert!(svc
6005            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6006            .await
6007            .is_err());
6008        svc.shutdown().await;
6009    }
6010
6011    #[tokio::test]
6012    async fn open_focuses_an_existing_absolute_dir() {
6013        let dir = tempfile::tempdir().unwrap();
6014        let svc = WorktreesService::new();
6015        // Pin the launcher to a harmless binary so the spawn deterministically
6016        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6017        // path, `open` takes the folder straight from the payload — no prior
6018        // `register` is required.
6019        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6020        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6021        let reply = svc
6022            .handle("open", json!({ "path": dir.path() }))
6023            .await
6024            .unwrap();
6025        assert_eq!(reply, json!({ "ok": true }));
6026        svc.shutdown().await;
6027    }
6028
6029    #[test]
6030    fn focus_window_with_validates_folder_then_spawns() {
6031        let dir = tempfile::tempdir().unwrap();
6032        // Non-absolute and missing-directory folders are rejected before spawn.
6033        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6034        assert!(
6035            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6036        );
6037        // A valid absolute directory spawns the launcher successfully.
6038        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6039        // A missing launcher surfaces the spawn error (with context), not Ok.
6040        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6041    }
6042
6043    #[test]
6044    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6045        // Env override wins outright.
6046        assert_eq!(
6047            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6048            PathBuf::from("/custom/code")
6049        );
6050        // No override: the first existing candidate is chosen.
6051        let existing = tempfile::NamedTempFile::new().unwrap();
6052        let existing_path = existing.path().to_str().unwrap();
6053        assert_eq!(
6054            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6055            PathBuf::from(existing_path)
6056        );
6057        // Nothing exists: fall back to bare `code` on PATH.
6058        assert_eq!(
6059            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6060            PathBuf::from("code")
6061        );
6062        // The real-env wrapper resolves without panicking.
6063        let _ = resolve_code_binary();
6064    }
6065
6066    // --- Git enrichment (#1186) --------------------------------------------
6067
6068    /// Initializes a fresh repo with a deterministic identity so `commit()`
6069    /// works without depending on a global git config.
6070    fn init_repo(dir: &Path) -> Repository {
6071        let repo = Repository::init(dir).unwrap();
6072        let mut cfg = repo.config().unwrap();
6073        cfg.set_str("user.name", "Test").unwrap();
6074        cfg.set_str("user.email", "test@example.com").unwrap();
6075        repo
6076    }
6077
6078    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6079    /// optionally moving `refname` to it, and returns its oid.
6080    fn empty_commit(
6081        repo: &Repository,
6082        refname: Option<&str>,
6083        parents: &[&git2::Commit<'_>],
6084        msg: &str,
6085    ) -> git2::Oid {
6086        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6087        let tree = repo
6088            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6089            .unwrap();
6090        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6091            .unwrap()
6092    }
6093
6094    /// Commits `content` as file `name` onto `refname`, chaining off its current
6095    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6096    /// the file is checked out into a worktree and can then be modified to
6097    /// produce a dirty (tracked) status.
6098    fn commit_file(
6099        repo: &Repository,
6100        refname: &str,
6101        name: &str,
6102        content: &[u8],
6103        msg: &str,
6104    ) -> git2::Oid {
6105        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6106        let blob = repo.blob(content).unwrap();
6107        let mut builder = repo.treebuilder(None).unwrap();
6108        builder.insert(name, blob, 0o100_644).unwrap();
6109        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6110        let parent = repo
6111            .refname_to_id(refname)
6112            .ok()
6113            .and_then(|oid| repo.find_commit(oid).ok());
6114        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6115        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6116            .unwrap()
6117    }
6118
6119    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6120    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6121    fn diverging_repo(dir: &Path) -> Repository {
6122        let repo = init_repo(dir);
6123        // A: the shared base on `main`.
6124        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6125        let a_commit = repo.find_commit(a).unwrap();
6126        // origin/main diverges to C, a sibling of the local tip.
6127        let c = empty_commit(&repo, None, &[&a_commit], "C");
6128        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6129            .unwrap();
6130        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6131        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6132        // Release the commit's borrow of `repo` so it can be returned.
6133        drop(a_commit);
6134        repo.set_head("refs/heads/main").unwrap();
6135        // Configure the tracking relationship so `upstream()` resolves.
6136        let mut cfg = repo.config().unwrap();
6137        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6138            .unwrap();
6139        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6140            .unwrap();
6141        cfg.set_str("branch.main.remote", "origin").unwrap();
6142        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6143        repo
6144    }
6145
6146    #[test]
6147    fn git_status_reads_branch_and_ahead_behind() {
6148        let dir = tempfile::tempdir().unwrap();
6149        let _repo = diverging_repo(dir.path());
6150        let status = git_status(dir.path());
6151        assert_eq!(status.branch.as_deref(), Some("main"));
6152        assert_eq!(status.ahead, Some(1));
6153        assert_eq!(status.behind, Some(1));
6154        // A normal checkout names itself and is not flagged a worktree.
6155        assert_eq!(
6156            status.main_repo.as_deref(),
6157            dir.path().file_name().and_then(|n| n.to_str())
6158        );
6159        assert!(!status.is_worktree);
6160    }
6161
6162    #[test]
6163    fn git_status_empty_repo_is_unborn() {
6164        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6165        // branch/sync fields stay empty rather than panicking — but the repo
6166        // identity is still resolved from the common dir.
6167        let dir = tempfile::tempdir().unwrap();
6168        init_repo(dir.path());
6169        let status = git_status(dir.path());
6170        assert_eq!(status.branch, None);
6171        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6172        assert_eq!(status.head_sha, None);
6173        assert_eq!(status.ahead, None);
6174        assert_eq!(status.behind, None);
6175        assert_eq!(
6176            status.main_repo.as_deref(),
6177            dir.path().file_name().and_then(|n| n.to_str())
6178        );
6179        assert!(!status.is_worktree);
6180    }
6181
6182    #[test]
6183    fn git_status_no_upstream_reports_branch_only() {
6184        let dir = tempfile::tempdir().unwrap();
6185        let repo = init_repo(dir.path());
6186        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6187        repo.set_head("refs/heads/main").unwrap();
6188        let status = git_status(dir.path());
6189        assert_eq!(status.branch.as_deref(), Some("main"));
6190        // No upstream → ahead/behind stay absent rather than zero.
6191        assert_eq!(status.ahead, None);
6192        assert_eq!(status.behind, None);
6193        // …and so does the upstream SHA, so such a branch still renders with no
6194        // sync indicator at all (#1344).
6195        assert_eq!(status.upstream_sha, None);
6196    }
6197
6198    #[test]
6199    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6200        // A plain directory that is not a git repo yields nothing at all.
6201        let plain = tempfile::tempdir().unwrap();
6202        assert_eq!(git_status(plain.path()), GitStatus::default());
6203
6204        // A detached HEAD reports no branch (and thus no sync), but the repo
6205        // identity is still resolved from the common dir.
6206        let dir = tempfile::tempdir().unwrap();
6207        let repo = init_repo(dir.path());
6208        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6209        repo.set_head_detached(a).unwrap();
6210        let status = git_status(dir.path());
6211        assert_eq!(status.branch, None);
6212        // A detached HEAD has no branch but *does* have a commit — the SHA is
6213        // resolved before the branch filter, so it survives here (#1337).
6214        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6215        assert_eq!(status.ahead, None);
6216        assert_eq!(status.behind, None);
6217        // A detached HEAD has no branch, so there is no upstream to resolve
6218        // either — the branch filter returns before the wrap (#1344).
6219        assert_eq!(status.upstream_sha, None);
6220        assert_eq!(
6221            status.main_repo.as_deref(),
6222            dir.path().file_name().and_then(|n| n.to_str())
6223        );
6224        assert!(!status.is_worktree);
6225    }
6226
6227    // --- Lazy ahead/behind (#1306) -----------------------------------------
6228
6229    #[test]
6230    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6231        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6232        // the streamed tree snapshot still reads the branch and repo identity, but
6233        // leaves ahead/behind absent — divergence is now lazy (#1306).
6234        let dir = tempfile::tempdir().unwrap();
6235        let repo = diverging_repo(dir.path());
6236        let status = git_status_cheap(dir.path());
6237        assert_eq!(status.branch.as_deref(), Some("main"));
6238        assert_eq!(status.ahead, None);
6239        assert_eq!(status.behind, None);
6240        assert_eq!(
6241            status.main_repo.as_deref(),
6242            dir.path().file_name().and_then(|n| n.to_str())
6243        );
6244        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6245        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6246        let head = repo.head().unwrap().target().unwrap();
6247        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6248    }
6249
6250    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6251
6252    #[test]
6253    fn git_status_head_sha_tracks_new_commits() {
6254        // The regression #1337 turns on: a commit must change the status the
6255        // snapshot is built from. Before the SHA rode the payload, committing
6256        // changed nothing on the wire, the server's diff dropped the identical
6257        // snapshot, and no client re-rendered — so a badge computed for the old
6258        // head survived the push that invalidated it.
6259        let dir = tempfile::tempdir().unwrap();
6260        let repo = init_repo(dir.path());
6261        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6262        repo.set_head("refs/heads/main").unwrap();
6263        let before = git_status_cheap(dir.path());
6264        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6265
6266        let head = repo.find_commit(a).unwrap();
6267        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6268        let after = git_status_cheap(dir.path());
6269        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6270        assert_ne!(before.head_sha, after.head_sha);
6271        // The branch is unchanged — the SHA is the *only* thing that moved, which
6272        // is exactly why its absence made the push invisible.
6273        assert_eq!(before.branch, after.branch);
6274    }
6275
6276    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6277
6278    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6279    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6280    /// these tests exercise a push with no network and no second repo.
6281    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6282        repo.reference("refs/remotes/origin/main", oid, true, "push")
6283            .unwrap();
6284    }
6285
6286    #[test]
6287    fn git_status_upstream_sha_tracks_a_push() {
6288        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6289        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6290        // remote-tracking ref, so before this field rode the payload every wire
6291        // field was byte-identical across the push, the server's diff dropped the
6292        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6293        // never re-asked — the row showed `↑1 ↓0` forever.
6294        let dir = tempfile::tempdir().unwrap();
6295        let repo = diverging_repo(dir.path());
6296        let before = git_status(dir.path());
6297        assert_eq!(before.ahead, Some(1));
6298        assert_eq!(before.behind, Some(1));
6299
6300        let head = repo.head().unwrap().target().unwrap();
6301        simulate_push(&repo, head);
6302        let after = git_status(dir.path());
6303
6304        // The upstream now names the pushed commit, and the counts agree.
6305        assert_eq!(
6306            after.upstream_sha.as_deref(),
6307            Some(head.to_string().as_str())
6308        );
6309        assert_ne!(before.upstream_sha, after.upstream_sha);
6310        assert_eq!(after.ahead, Some(0));
6311        assert_eq!(after.behind, Some(0));
6312        // Nothing else moved — which is the whole point. A push leaves the branch
6313        // and the local head exactly where they were, so `upstream_sha` is the
6314        // only signal a client could possibly notice.
6315        assert_eq!(before.branch, after.branch);
6316        assert_eq!(before.head_sha, after.head_sha);
6317    }
6318
6319    #[test]
6320    fn git_status_cheap_reports_upstream_sha() {
6321        // The crux: the field has to ride the *cheap* path, since that is the one
6322        // the streamed snapshot is built from. Costing a config lookup and a refs
6323        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6324        // walk still absent here.
6325        let dir = tempfile::tempdir().unwrap();
6326        let repo = diverging_repo(dir.path());
6327        let status = git_status_cheap(dir.path());
6328        let upstream = repo
6329            .find_branch("origin/main", git2::BranchType::Remote)
6330            .unwrap()
6331            .get()
6332            .target()
6333            .unwrap();
6334        assert_eq!(
6335            status.upstream_sha.as_deref(),
6336            Some(upstream.to_string().as_str())
6337        );
6338        assert_eq!(status.ahead, None);
6339        assert_eq!(status.behind, None);
6340    }
6341
6342    #[test]
6343    fn folder_ahead_behind_computes_divergence_and_degrades() {
6344        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6345        let dir = tempfile::tempdir().unwrap();
6346        let _repo = diverging_repo(dir.path());
6347        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6348
6349        // A branch with no upstream → None (the tree renders no sync indicator).
6350        let no_up = tempfile::tempdir().unwrap();
6351        let repo = init_repo(no_up.path());
6352        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6353        repo.set_head("refs/heads/main").unwrap();
6354        assert_eq!(folder_ahead_behind(no_up.path()), None);
6355
6356        // A detached HEAD and a plain (non-repo) directory → None.
6357        let detached = tempfile::tempdir().unwrap();
6358        let drepo = init_repo(detached.path());
6359        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6360        drepo.set_head_detached(a).unwrap();
6361        assert_eq!(folder_ahead_behind(detached.path()), None);
6362        let plain = tempfile::tempdir().unwrap();
6363        assert_eq!(folder_ahead_behind(plain.path()), None);
6364    }
6365
6366    #[tokio::test]
6367    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6368        let diverging = tempfile::tempdir().unwrap();
6369        let _d = diverging_repo(diverging.path());
6370        let no_up = tempfile::tempdir().unwrap();
6371        let repo = init_repo(no_up.path());
6372        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6373        repo.set_head("refs/heads/main").unwrap();
6374
6375        let svc = WorktreesService::new();
6376        let diverging_path = diverging.path().display().to_string();
6377        let no_up_path = no_up.path().display().to_string();
6378        let reply = svc
6379            .handle(
6380                "ahead-behind",
6381                json!({ "paths": [&diverging_path, &no_up_path] }),
6382            )
6383            .await
6384            .unwrap();
6385        let results = reply.get("results").unwrap();
6386        // The diverging worktree carries its counts, keyed by the requested path.
6387        let d = results.get(diverging_path.as_str()).unwrap();
6388        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6389        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6390        // The no-upstream worktree is omitted entirely, not reported as zero.
6391        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6392
6393        // A missing/empty `paths` list yields an empty results object, not an error.
6394        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6395        assert_eq!(empty.get("results"), Some(&json!({})));
6396    }
6397
6398    #[tokio::test]
6399    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6400        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
6401        let dir = tempfile::tempdir().unwrap();
6402        let _repo = diverging_repo(dir.path());
6403        let svc = WorktreesService::new();
6404        svc.handle(
6405            "register",
6406            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6407        )
6408        .await
6409        .unwrap();
6410
6411        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6412        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6413        let main_wt = &worktrees[0];
6414        // The cheap parts are present, but divergence is not — it is fetched
6415        // lazily via the `ahead-behind` op (#1306).
6416        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6417        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6418        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6419    }
6420
6421    #[tokio::test]
6422    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6423        // The end-to-end shape of the #1337 freshness fix. The server pushes a
6424        // snapshot only when it differs from the last one
6425        // (`server.rs`: `if snap != last`), so anything invisible on the wire
6426        // cannot trigger a re-render. Committing must therefore move the payload.
6427        let dir = tempfile::tempdir().unwrap();
6428        let repo = init_repo(dir.path());
6429        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6430        repo.set_head("refs/heads/main").unwrap();
6431
6432        let svc = WorktreesService::new();
6433        svc.handle(
6434            "register",
6435            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6436        )
6437        .await
6438        .unwrap();
6439
6440        let before = svc.handle("tree", Value::Null).await.unwrap();
6441        let wt = &repos_of(&before)[0]["worktrees"][0];
6442        assert_eq!(
6443            wt.get("head_sha").and_then(Value::as_str),
6444            Some(a.to_string().as_str())
6445        );
6446
6447        // Commit again: same branch, same paths, same open windows — pre-#1337 the
6448        // snapshot was byte-identical here and the push was dropped.
6449        let head = repo.find_commit(a).unwrap();
6450        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6451        let after = svc.handle("tree", Value::Null).await.unwrap();
6452        assert_eq!(
6453            repos_of(&after)[0]["worktrees"][0]
6454                .get("head_sha")
6455                .and_then(Value::as_str),
6456            Some(b.to_string().as_str())
6457        );
6458        assert_ne!(before, after, "a commit must be a visible snapshot delta");
6459    }
6460
6461    #[tokio::test]
6462    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6463        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
6464        // matching the payload's `skip_serializing_if` convention.
6465        let dir = tempfile::tempdir().unwrap();
6466        init_repo(dir.path());
6467        let svc = WorktreesService::new();
6468        svc.handle(
6469            "register",
6470            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6471        )
6472        .await
6473        .unwrap();
6474        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6475        assert!(wt.get("head_sha").is_none(), "{wt:?}");
6476    }
6477
6478    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6479
6480    #[tokio::test]
6481    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
6482        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
6483        // moves neither the branch nor the local head, so `upstream_sha` is the
6484        // only field that can carry the news. Without it the snapshot serialised
6485        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
6486        // window re-rendered, and the lazy ahead/behind was never re-fetched.
6487        let dir = tempfile::tempdir().unwrap();
6488        let repo = diverging_repo(dir.path());
6489        let svc = WorktreesService::new();
6490        svc.handle(
6491            "register",
6492            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6493        )
6494        .await
6495        .unwrap();
6496
6497        let before = svc.handle("tree", Value::Null).await.unwrap();
6498        let head = repo.head().unwrap().target().unwrap();
6499        assert_ne!(
6500            repos_of(&before)[0]["worktrees"][0]
6501                .get("upstream_sha")
6502                .and_then(Value::as_str),
6503            Some(head.to_string().as_str()),
6504            "the fixture must start un-pushed for this to prove anything"
6505        );
6506
6507        // Push: only `refs/remotes/origin/main` moves.
6508        simulate_push(&repo, head);
6509        let after = svc.handle("tree", Value::Null).await.unwrap();
6510        let wt = &repos_of(&after)[0]["worktrees"][0];
6511        assert_eq!(
6512            wt.get("upstream_sha").and_then(Value::as_str),
6513            Some(head.to_string().as_str())
6514        );
6515        // The head and branch are untouched across the push — so this delta rests
6516        // entirely on `upstream_sha`.
6517        assert_eq!(
6518            wt.get("head_sha").and_then(Value::as_str),
6519            repos_of(&before)[0]["worktrees"][0]
6520                .get("head_sha")
6521                .and_then(Value::as_str)
6522        );
6523        assert_ne!(before, after, "a push must be a visible snapshot delta");
6524    }
6525
6526    #[tokio::test]
6527    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
6528        // Wire-compat, and the no-regression case: a branch tracking nothing sends
6529        // no key at all rather than a null, so an older client sees exactly the
6530        // payload it saw before and still renders no sync indicator.
6531        let dir = tempfile::tempdir().unwrap();
6532        let repo = init_repo(dir.path());
6533        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6534        repo.set_head("refs/heads/main").unwrap();
6535        let svc = WorktreesService::new();
6536        svc.handle(
6537            "register",
6538            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6539        )
6540        .await
6541        .unwrap();
6542        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6543        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
6544        // The head still rides, so this is specifically the upstream degrading.
6545        assert!(wt.get("head_sha").is_some(), "{wt:?}");
6546    }
6547
6548    // --- PR badge poller (#1337) -------------------------------------------
6549
6550    /// Writes an executable stub that ignores its arguments and prints `stdout`,
6551    /// standing in for `gh api graphql` so the poll loop is exercised offline.
6552    /// Returns the shim lock alongside the path: the caller **must** hold the
6553    /// guard until the poller has finished exec'ing the stub. Writing an
6554    /// executable and then `execve`ing it races every other thread that forks —
6555    /// the child inherits the still-open writable FD and the exec fails
6556    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
6557    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
6558        let guard = shim_lock();
6559        let path = dir.join("fake-gh");
6560        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
6561        (path, guard)
6562    }
6563
6564    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
6565    /// byte to a counter file before printing `stdout`. The returned counter path
6566    /// lets a test assert how many `gh` subprocesses actually ran, independent of
6567    /// the #1387 request-log counter — so the two can be compared (#1389).
6568    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
6569        let guard = shim_lock();
6570        let path = dir.join("fake-gh");
6571        let counter = dir.join("gh-calls");
6572        write_exec_script(
6573            &path,
6574            &format!(
6575                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
6576                counter = counter.display()
6577            ),
6578        );
6579        (path, guard, counter)
6580    }
6581
6582    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
6583    /// ran) — the length of the counter file.
6584    fn gh_spawn_count(counter: &Path) -> usize {
6585        std::fs::read(counter).map_or(0, |b| b.len())
6586    }
6587
6588    /// The number of **successful** `kind: "gh"` records the #1387 choke point
6589    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
6590    /// on the exit code matches the ground-truth counter (which is written when the
6591    /// stub *runs*), so a rare failed spawn cannot desync the two.
6592    fn counted_gh_records(log: &Path) -> usize {
6593        std::fs::read_to_string(log)
6594            .unwrap_or_default()
6595            .lines()
6596            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
6597            .count()
6598    }
6599
6600    /// A repo with a GitHub origin and one commit on `main`.
6601    fn github_repo(dir: &Path) -> Repository {
6602        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
6603    }
6604
6605    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
6606    /// test can register **distinct** targets (owner/name) across windows.
6607    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
6608        let repo = init_repo(dir);
6609        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6610        repo.set_head("refs/heads/main").unwrap();
6611        repo.remote("origin", url).unwrap();
6612        repo
6613    }
6614
6615    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
6616    /// because the fold downgrades a badge naming a different commit than the
6617    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
6618    /// wrong reason.
6619    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
6620        PrBadge {
6621            number,
6622            is_draft: false,
6623            checks: PrCheckState::Pending,
6624            url: "u".into(),
6625            head_oid: head_oid.to_string(),
6626        }
6627    }
6628
6629    /// Wraps a badge as the cache's resolution value (#1370).
6630    fn pr(badge: PrBadge) -> PrResolution {
6631        PrResolution::Pr(badge)
6632    }
6633
6634    #[test]
6635    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
6636        let snapshot = json!({"repos":[
6637            {
6638                "main_repo":"omni-dev",
6639                "github":{"owner":"rust-works","name":"omni-dev"},
6640                "root":"/r",
6641                // Enabled (#1376) — a not-polled repo contributes no targets (see
6642                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
6643                "polling_enabled":true,
6644                // Two worktrees on the same branch must ask once, not twice.
6645                "worktrees":[
6646                    {"path":"/r","branch":"main","is_main":true,"open":true},
6647                    {"path":"/w1","branch":"main","is_main":false,"open":true},
6648                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
6649                    // Detached: no branch, so nothing to resolve.
6650                    {"path":"/w3","is_main":false,"open":true}
6651                ]
6652            },
6653            {
6654                // Not on GitHub: contributes no targets at all.
6655                "main_repo":"local","root":"/l",
6656                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
6657            }
6658        ]});
6659        let targets = pr_targets_from_snapshot(&snapshot);
6660        assert_eq!(
6661            targets,
6662            vec![
6663                PrTarget {
6664                    owner: "rust-works".into(),
6665                    name: "omni-dev".into(),
6666                    branch: "feature".into()
6667                },
6668                PrTarget {
6669                    owner: "rust-works".into(),
6670                    name: "omni-dev".into(),
6671                    branch: "main".into()
6672                },
6673            ]
6674        );
6675    }
6676
6677    #[test]
6678    fn pr_targets_from_snapshot_is_empty_without_repos() {
6679        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
6680        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
6681    }
6682
6683    #[test]
6684    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
6685        // Defensive: a `github` object without usable owner/name strings yields no
6686        // target rather than a half-built query.
6687        for github in [
6688            json!({}),
6689            json!({"owner": "o"}),
6690            json!({"owner": 1, "name": 2}),
6691        ] {
6692            let snapshot = json!({"repos":[{
6693                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
6694                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6695            }]});
6696            assert!(
6697                pr_targets_from_snapshot(&snapshot).is_empty(),
6698                "{snapshot:?}"
6699            );
6700        }
6701    }
6702
6703    #[test]
6704    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
6705        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
6706        // absent (the default-off case) or explicitly false contributes no watch,
6707        // so the poll never mentions it. Only an enabled repo is polled.
6708        for repo in [
6709            json!({
6710                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6711                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6712            }),
6713            json!({
6714                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6715                "polling_enabled":false,
6716                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6717            }),
6718        ] {
6719            let snapshot = json!({ "repos": [repo] });
6720            assert!(
6721                pr_targets_from_snapshot(&snapshot).is_empty(),
6722                "not-polled repo must yield no targets: {snapshot:?}"
6723            );
6724        }
6725        // Flipping the same repo to enabled makes it contribute.
6726        let enabled = json!({"repos":[{
6727            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
6728            "polling_enabled":true,
6729            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
6730        }]});
6731        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
6732    }
6733
6734    #[test]
6735    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
6736        let backoff = Duration::from_secs(600);
6737        // Never fetched: go.
6738        assert!(pr_should_fetch(false, None, backoff));
6739        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
6740        // spend nothing.
6741        assert!(!pr_should_fetch(
6742            false,
6743            Some(Duration::from_secs(1)),
6744            backoff
6745        ));
6746        // Quiet tree, backoff elapsed: time to look again.
6747        assert!(pr_should_fetch(false, Some(backoff), backoff));
6748        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
6749        // The load-bearing case: the watch grew (a target added, or an upstream
6750        // pushed), so fetch **now** regardless of how deep the backoff had grown.
6751        // Without this a push waits out the full ceiling on a stale badge.
6752        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
6753        assert!(pr_should_fetch(
6754            true,
6755            Some(Duration::from_millis(1)),
6756            backoff
6757        ));
6758    }
6759
6760    #[test]
6761    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
6762        let base = Duration::from_secs(10);
6763        let fresh = Some(Duration::ZERO);
6764        let stale = Some(PENDING_FAST_WINDOW);
6765        // Pending and fresh (within the fast window): hold `base`, however long we
6766        // had backed off for.
6767        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
6768        assert_eq!(
6769            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
6770            base
6771        );
6772        // Pending but past the fast window (a long CI run): escalate — double up to
6773        // the pending ceiling, never to the terminal one.
6774        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
6775        assert_eq!(
6776            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
6777            PENDING_MAX_INTERVAL
6778        );
6779        // Pending with nothing having moved yet (`None`) is treated as past the fast
6780        // window, so a stale-from-boot pending state does not pin `base`.
6781        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
6782        // Everything terminal: double…
6783        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
6784        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
6785        // …up to the terminal ceiling (above the pending one), and never overflow.
6786        assert_eq!(
6787            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
6788            MAX_PR_POLL_INTERVAL
6789        );
6790        assert_eq!(
6791            next_pr_poll_delay(Duration::MAX, base, false, None),
6792            MAX_PR_POLL_INTERVAL
6793        );
6794    }
6795
6796    /// A watch on one branch with the given upstream tip.
6797    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
6798        PrWatch {
6799            target: PrTarget {
6800                owner: "rust-works".into(),
6801                name: "omni-dev".into(),
6802                branch: branch.into(),
6803            },
6804            upstream_sha: upstream.map(str::to_string),
6805        }
6806    }
6807
6808    #[test]
6809    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
6810        let a = watch("a", Some("111"));
6811        let b = watch("b", Some("222"));
6812        let ab = [a.clone(), b.clone()];
6813        let just_a = std::slice::from_ref(&a);
6814        let just_b = std::slice::from_ref(&b);
6815        // Nothing new: quiet tick.
6816        assert!(!pr_watch_grew(&ab, &ab));
6817        // Addition: a new target appeared.
6818        assert!(pr_watch_grew(just_a, &ab));
6819        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
6820        assert!(!pr_watch_grew(&ab, just_a));
6821        // First sight (empty prev, from `None`): everything is new.
6822        assert!(pr_watch_grew(&[], just_a));
6823        // A push moves only the upstream — still "grew".
6824        let a_pushed = [watch("a", Some("999"))];
6825        assert!(pr_watch_grew(just_a, &a_pushed));
6826        // Gaining a target while losing another still fetches (the gain wins).
6827        assert!(pr_watch_grew(just_a, just_b));
6828    }
6829
6830    #[test]
6831    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
6832        let base = Duration::from_secs(10);
6833        let over = RateLimitSnapshot {
6834            graphql: Some(rl_resource(90)),
6835            core: Some(rl_resource(3)),
6836            search: None,
6837        };
6838        let under = RateLimitSnapshot {
6839            graphql: Some(rl_resource(50)),
6840            core: Some(rl_resource(3)),
6841            search: None,
6842        };
6843        // No reading yet: unchanged.
6844        assert_eq!(budget_throttled_delay(base, None), base);
6845        // Under the warn threshold: unchanged.
6846        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
6847        // Over: raised to at least the throttle floor…
6848        assert_eq!(
6849            budget_throttled_delay(base, Some(&over)),
6850            BUDGET_THROTTLE_INTERVAL
6851        );
6852        // …but a delay already above the floor is left alone (never shortened).
6853        let long = BUDGET_THROTTLE_INTERVAL * 2;
6854        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
6855    }
6856
6857    #[test]
6858    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
6859        // The persisted cache must survive a JSON round trip with `head_oid` intact
6860        // — it is the staleness key the tree wire drops, and losing it would render
6861        // every restored badge stale (#1389, fix 4).
6862        let target = PrTarget {
6863            owner: "rust-works".into(),
6864            name: "omni-dev".into(),
6865            branch: "main".into(),
6866        };
6867        let badge = PrResolution::Pr(PrBadge {
6868            number: 1337,
6869            is_draft: true,
6870            checks: PrCheckState::Pending,
6871            url: "http://x/1337".into(),
6872            head_oid: "deadbeef".into(),
6873        });
6874        let watched = vec![watch("main", Some("abc"))];
6875        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
6876            .unwrap()
6877            .with_timezone(&Utc);
6878        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
6879
6880        let json = serde_json::to_vec(&prefs).unwrap();
6881        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
6882        assert_eq!(back, prefs);
6883        assert_eq!(back.polled_at, Some(polled_at));
6884        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
6885        // The restored resolution equals the original — head_oid and all.
6886        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
6887    }
6888
6889    #[test]
6890    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
6891        // The explicit negative must survive persistence too: restoring `NoPr`
6892        // as "absent" would lose the #1370 distinction across a restart and
6893        // re-ask GitHub for branches already known to have no PR.
6894        let target = PrTarget {
6895            owner: "rust-works".into(),
6896            name: "omni-dev".into(),
6897            branch: "feature".into(),
6898        };
6899        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
6900        let json = serde_json::to_vec(&prefs).unwrap();
6901        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
6902        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
6903        assert_eq!(
6904            back.entries[0].resolution.clone().into_resolution(),
6905            PrResolution::NoPr
6906        );
6907    }
6908
6909    #[test]
6910    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
6911        // A file with verdicts but no `polled_at` (an older shape, or a
6912        // hand-edited one) still renders badges, but must not arm the warm
6913        // start: without a poll time there is nothing to age the verdicts
6914        // against, so the poller re-polls immediately (#1389, fix 4).
6915        let dir = tempfile::tempdir().unwrap();
6916        let path = dir.path().join("pr-cache.json");
6917        let target = PrTarget {
6918            owner: "rust-works".into(),
6919            name: "omni-dev".into(),
6920            branch: "main".into(),
6921        };
6922        let mut prefs = pr_cache_prefs_from(
6923            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
6924            &[watch("main", None)],
6925            Utc::now(),
6926        );
6927        prefs.polled_at = None;
6928        write_pr_cache(&path, &prefs).unwrap();
6929
6930        let svc = WorktreesService::new();
6931        svc.load_pr_cache(path);
6932        assert!(
6933            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
6934            "the badge itself must still restore"
6935        );
6936        assert!(
6937            svc.pr_warm_start
6938                .lock()
6939                .unwrap_or_else(PoisonError::into_inner)
6940                .is_none(),
6941            "no poll time means a cold start, not a trusted warm one"
6942        );
6943    }
6944
6945    /// Installs a thread-local WARN-level subscriber for the duration of a
6946    /// test, so degraded-path `tracing::warn!` sites actually format their
6947    /// fields instead of short-circuiting on "nobody is listening".
6948    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
6949        tracing::subscriber::set_default(
6950            tracing_subscriber::fmt()
6951                .with_max_level(tracing::Level::WARN)
6952                .with_writer(std::io::sink)
6953                .finish(),
6954        )
6955    }
6956
6957    #[test]
6958    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
6959        // The best-effort contract: a mangled cache is logged and treated as
6960        // empty — never a panic — and the path is stored regardless, so the
6961        // next successful poll rewrites a clean file (#1389, fix 4).
6962        let _trace = warn_subscriber();
6963        let dir = tempfile::tempdir().unwrap();
6964        let corrupt = dir.path().join("pr-cache.json");
6965        std::fs::write(&corrupt, b"not json").unwrap();
6966        let svc = WorktreesService::new();
6967        svc.load_pr_cache(corrupt.clone());
6968        assert!(svc.pr_cache.entries().is_empty());
6969        assert_eq!(
6970            svc.pr_cache_path
6971                .lock()
6972                .unwrap_or_else(PoisonError::into_inner)
6973                .as_deref(),
6974            Some(corrupt.as_path()),
6975            "the path must be stored even when the load fails, so persistence recovers"
6976        );
6977
6978        // A directory: `read` fails with a non-NotFound error (the distinct
6979        // "could not read" arm), with the same treated-as-empty outcome.
6980        let svc = WorktreesService::new();
6981        svc.load_pr_cache(dir.path().to_path_buf());
6982        assert!(svc.pr_cache.entries().is_empty());
6983    }
6984
6985    #[test]
6986    fn persist_pr_cache_swallows_a_write_failure() {
6987        // Best-effort: an unwritable path is logged at WARN and swallowed — the
6988        // in-memory cache stays authoritative, and losing the warm start only
6989        // costs one extra poll after the next restart.
6990        let _trace = warn_subscriber();
6991        let dir = tempfile::tempdir().unwrap();
6992        let blocker = dir.path().join("blocker");
6993        std::fs::write(&blocker, b"").unwrap();
6994        // The parent is a regular file, so creating the runtime dir must fail.
6995        let path = blocker.join("pr-cache.json");
6996        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
6997        assert!(!path.exists());
6998        // The root has no parent at all — nothing to create, and the write
6999        // itself fails (it is a directory); still swallowed.
7000        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7001    }
7002
7003    #[tokio::test]
7004    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7005        let dir = tempfile::tempdir().unwrap();
7006        let repo = github_repo(dir.path());
7007        let head = repo.head().unwrap().target().unwrap().to_string();
7008        let svc = WorktreesService::new();
7009        svc.handle(
7010            "register",
7011            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7012        )
7013        .await
7014        .unwrap();
7015        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7016        // act on it, as if the user had toggled it on.
7017        svc.registry.set_polling("rust-works", "omni-dev", true);
7018
7019        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
7020        // and so is the negative, so "not resolved" stays distinguishable (#1370).
7021        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7022        assert!(wt.get("pr").is_none(), "{wt:?}");
7023        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7024
7025        // Seed the cache the poller writes, then re-read the tree.
7026        let mut badges = HashMap::new();
7027        badges.insert(
7028            PrTarget {
7029                owner: "rust-works".into(),
7030                name: "omni-dev".into(),
7031                branch: "main".into(),
7032            },
7033            pr(pending_badge(1337, &head)),
7034        );
7035        assert!(svc.pr_cache.replace(badges));
7036
7037        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7038        assert_eq!(wt["pr"]["number"], json!(1337));
7039        assert_eq!(wt["pr"]["checks"], json!("pending"));
7040        // camelCase on the wire, or the extension silently loses the draft marker.
7041        assert_eq!(wt["pr"]["isDraft"], json!(false));
7042        // A badge and the negative are mutually exclusive.
7043        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7044    }
7045
7046    #[tokio::test]
7047    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7048        // A detached HEAD has a commit but no branch, and a badge is keyed by
7049        // branch — so there is nothing to match. It must fall through silently
7050        // rather than borrow a badge from whatever branch happens to be cached, and
7051        // rather than sink the tree.
7052        let dir = tempfile::tempdir().unwrap();
7053        let repo = github_repo(dir.path());
7054        let head = repo.head().unwrap().target().unwrap();
7055        repo.set_head_detached(head).unwrap();
7056
7057        let svc = WorktreesService::new();
7058        svc.handle(
7059            "register",
7060            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7061        )
7062        .await
7063        .unwrap();
7064        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7065        // act on it, as if the user had toggled it on.
7066        svc.registry.set_polling("rust-works", "omni-dev", true);
7067        // A badge *is* cached for `main` — the branch this worktree was on before
7068        // detaching. It must not leak onto the now-branchless row.
7069        let mut badges = HashMap::new();
7070        badges.insert(
7071            PrTarget {
7072                owner: "rust-works".into(),
7073                name: "omni-dev".into(),
7074                branch: "main".into(),
7075            },
7076            pr(pending_badge(1, &head.to_string())),
7077        );
7078        svc.pr_cache.replace(badges);
7079
7080        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7081        assert!(wt.get("branch").is_none(), "{wt:?}");
7082        // The SHA still shows — detached means no branch, not no commit.
7083        assert_eq!(
7084            wt.get("head_sha").and_then(Value::as_str),
7085            Some(head.to_string().as_str())
7086        );
7087        assert!(wt.get("pr").is_none(), "{wt:?}");
7088        // No branch means nothing was checked either: no negative on the row.
7089        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7090    }
7091
7092    #[tokio::test]
7093    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7094        let dir = tempfile::tempdir().unwrap();
7095        github_repo(dir.path());
7096        let svc = WorktreesService::new();
7097        svc.handle(
7098            "register",
7099            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7100        )
7101        .await
7102        .unwrap();
7103        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7104        // act on it, as if the user had toggled it on.
7105        svc.registry.set_polling("rust-works", "omni-dev", true);
7106        // A badge for a different branch must not leak onto `main`.
7107        let mut badges = HashMap::new();
7108        badges.insert(
7109            PrTarget {
7110                owner: "rust-works".into(),
7111                name: "omni-dev".into(),
7112                branch: "other".into(),
7113            },
7114            pr(pending_badge(1, "irrelevant")),
7115        );
7116        svc.pr_cache.replace(badges);
7117        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7118        assert!(wt.get("pr").is_none(), "{wt:?}");
7119        // An unmatched branch is unresolved, not negative.
7120        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7121    }
7122
7123    #[tokio::test]
7124    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7125        // The #1370 fix on the wire: a branch the poller checked and found PR-less
7126        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
7127        // tell "checked, none" from "not resolved" and keep its fallback quiet.
7128        let dir = tempfile::tempdir().unwrap();
7129        github_repo(dir.path());
7130        let svc = WorktreesService::new();
7131        svc.handle(
7132            "register",
7133            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7134        )
7135        .await
7136        .unwrap();
7137        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7138        // act on it, as if the user had toggled it on.
7139        svc.registry.set_polling("rust-works", "omni-dev", true);
7140
7141        let mut resolutions = HashMap::new();
7142        resolutions.insert(
7143            PrTarget {
7144                owner: "rust-works".into(),
7145                name: "omni-dev".into(),
7146                branch: "main".into(),
7147            },
7148            PrResolution::NoPr,
7149        );
7150        assert!(svc.pr_cache.replace(resolutions));
7151
7152        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7153        assert_eq!(wt["pr_none"], json!(true));
7154        // Mutually exclusive with a badge.
7155        assert!(wt.get("pr").is_none(), "{wt:?}");
7156    }
7157
7158    #[tokio::test]
7159    async fn a_commit_does_not_drop_a_negative_resolution() {
7160        // A negative has no commit to be stale against. Dropping it when HEAD
7161        // moves would flip the row back to "unresolved" on every local commit —
7162        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
7163        // poller's `moved` trigger re-checks the branch promptly instead.
7164        let dir = tempfile::tempdir().unwrap();
7165        let repo = github_repo(dir.path());
7166        let first = repo.head().unwrap().target().unwrap();
7167
7168        let svc = WorktreesService::new();
7169        svc.handle(
7170            "register",
7171            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7172        )
7173        .await
7174        .unwrap();
7175        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7176        // act on it, as if the user had toggled it on.
7177        svc.registry.set_polling("rust-works", "omni-dev", true);
7178
7179        let mut resolutions = HashMap::new();
7180        resolutions.insert(
7181            PrTarget {
7182                owner: "rust-works".into(),
7183                name: "omni-dev".into(),
7184                branch: "main".into(),
7185            },
7186            PrResolution::NoPr,
7187        );
7188        svc.pr_cache.replace(resolutions);
7189
7190        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7191        assert_eq!(wt["pr_none"], json!(true));
7192
7193        // Commit — as a push would leave things, with the cache untouched.
7194        let head = repo.find_commit(first).unwrap();
7195        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7196
7197        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7198        assert_eq!(
7199            wt["pr_none"],
7200            json!(true),
7201            "a local commit must not drop the negative"
7202        );
7203    }
7204
7205    #[tokio::test]
7206    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7207        // The idle case — the daemon runs all day with no editor open. Point it at a
7208        // stub that fails loudly if ever spawned: a poll here would both waste
7209        // GitHub budget and, on a real `gh`, wake the radio for nothing.
7210        let bin_dir = tempfile::tempdir().unwrap();
7211        let marker = bin_dir.path().join("spawned");
7212        let fake = bin_dir.path().join("fake-gh");
7213        std::fs::write(
7214            &fake,
7215            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7216        )
7217        .unwrap();
7218        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7219        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7220        std::fs::set_permissions(&fake, perms).unwrap();
7221
7222        let svc = WorktreesService::new();
7223        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7224        tokio::time::sleep(Duration::from_millis(200)).await;
7225        svc.shutdown().await;
7226        assert!(
7227            !marker.exists(),
7228            "the poller must not spawn gh with no windows registered"
7229        );
7230    }
7231
7232    #[tokio::test]
7233    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7234        // Badges are decoration: an unauthenticated or broken `gh` must never sink
7235        // the tree, and one bad poll must not blank rows that were fine a second ago.
7236        let dir = tempfile::tempdir().unwrap();
7237        let repo = github_repo(dir.path());
7238        let head = repo.head().unwrap().target().unwrap().to_string();
7239        let bin_dir = tempfile::tempdir().unwrap();
7240        let fake = bin_dir.path().join("fake-gh");
7241        // Exits non-zero, exactly as `gh` does without `gh auth login`.
7242        std::fs::write(
7243            &fake,
7244            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7245        )
7246        .unwrap();
7247        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7248        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7249        std::fs::set_permissions(&fake, perms).unwrap();
7250
7251        let svc = WorktreesService::new();
7252        svc.handle(
7253            "register",
7254            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7255        )
7256        .await
7257        .unwrap();
7258        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7259        // act on it, as if the user had toggled it on.
7260        svc.registry.set_polling("rust-works", "omni-dev", true);
7261        // Seed a badge as though an earlier poll had succeeded.
7262        let mut seeded = HashMap::new();
7263        seeded.insert(
7264            PrTarget {
7265                owner: "rust-works".into(),
7266                name: "omni-dev".into(),
7267                branch: "main".into(),
7268            },
7269            pr(pending_badge(7, &head)),
7270        );
7271        svc.pr_cache.replace(seeded);
7272
7273        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7274        tokio::time::sleep(Duration::from_millis(200)).await;
7275
7276        // The tree still serves, and the seeded badge survived the failing polls —
7277        // which also minted no false "no PR" negatives (#1370).
7278        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7279        assert_eq!(wt["pr"]["number"], json!(7));
7280        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7281        svc.shutdown().await;
7282    }
7283
7284    #[tokio::test]
7285    // The shim guard is deliberately held across the awaits below: it must span
7286    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
7287    // is against another test writing while this one forks. Safe here — only test
7288    // threads take it, never a task inside the runtime, so it cannot deadlock.
7289    // Scoped per-test rather than on the module, which would also silence the
7290    // registry lock's "never held across .await" invariant.
7291    #[allow(clippy::await_holding_lock)]
7292    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7293        // The normal startup order: the daemon starts at login, *before* any
7294        // editor. It therefore sees an empty tree and backs off to the 30-minute
7295        // ceiling — so unless a register wakes it, the first badge of the session
7296        // arrives up to half an hour after the window does, which reads as the
7297        // feature being broken rather than slow.
7298        let dir = tempfile::tempdir().unwrap();
7299        github_repo(dir.path());
7300        let bin_dir = tempfile::tempdir().unwrap();
7301        let (fake, _shim) = fake_gh(
7302            bin_dir.path(),
7303            r#"{"data":{"r0":{"b0":{
7304                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7305                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7306                ]}}},
7307                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7308            }}}}"#,
7309        );
7310
7311        let svc = WorktreesService::new();
7312        // Poller first, with nothing registered — it backs off on the empty tree.
7313        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7314        tokio::time::sleep(Duration::from_millis(150)).await;
7315
7316        // Now an editor opens.
7317        svc.handle(
7318            "register",
7319            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7320        )
7321        .await
7322        .unwrap();
7323        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7324        // act on it, as if the user had toggled it on.
7325        svc.registry.set_polling("rust-works", "omni-dev", true);
7326
7327        // The badge must follow promptly — the register wakes the loop out of its
7328        // backoff. The deadline is orders of magnitude below the ceiling, so this
7329        // fails on the bug rather than merely being slow.
7330        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7331            loop {
7332                if let Some(PrResolution::Pr(badge)) =
7333                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7334                {
7335                    return badge;
7336                }
7337                tokio::time::sleep(Duration::from_millis(25)).await;
7338            }
7339        })
7340        .await
7341        .expect("a window opening must wake the poller out of its idle backoff");
7342        assert_eq!(badge.number, 99);
7343        svc.shutdown().await;
7344    }
7345
7346    #[tokio::test]
7347    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7348        // The acceptance criterion: "pushing a new commit invalidates the badge
7349        // rather than leaving the previous head's verdict standing."
7350        //
7351        // The cache still holds the verdict for the *old* commit, and the poller may
7352        // have backed off for up to half an hour. So the fold — which runs on every
7353        // snapshot — has to notice on its own, with no network call.
7354        let dir = tempfile::tempdir().unwrap();
7355        let repo = github_repo(dir.path());
7356        let first = repo.head().unwrap().target().unwrap();
7357
7358        let svc = WorktreesService::new();
7359        svc.handle(
7360            "register",
7361            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7362        )
7363        .await
7364        .unwrap();
7365        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7366        // act on it, as if the user had toggled it on.
7367        svc.registry.set_polling("rust-works", "omni-dev", true);
7368
7369        // A green verdict, correctly describing the commit currently checked out.
7370        let mut badges = HashMap::new();
7371        badges.insert(
7372            PrTarget {
7373                owner: "rust-works".into(),
7374                name: "omni-dev".into(),
7375                branch: "main".into(),
7376            },
7377            pr(PrBadge {
7378                number: 1337,
7379                is_draft: false,
7380                checks: PrCheckState::Success,
7381                url: "u".into(),
7382                head_oid: first.to_string(),
7383            }),
7384        );
7385        svc.pr_cache.replace(badges);
7386
7387        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7388        assert_eq!(
7389            wt["pr"]["checks"],
7390            json!("success"),
7391            "green for its own commit"
7392        );
7393
7394        // Now commit — as a push would leave things, with the cache untouched.
7395        let head = repo.find_commit(first).unwrap();
7396        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7397
7398        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7399        assert_eq!(
7400            wt["pr"]["checks"],
7401            json!("pending"),
7402            "the previous commit's ✓ must not stand after a new commit"
7403        );
7404        // The PR itself is still shown — it is the *verdict* that is unknown, not
7405        // the PR.
7406        assert_eq!(wt["pr"]["number"], json!(1337));
7407    }
7408
7409    #[test]
7410    fn is_stale_for_compares_the_commit_the_verdict_describes() {
7411        let badge = pending_badge(1, "aaa");
7412        assert!(!badge.is_stale_for(Some("aaa")));
7413        assert!(badge.is_stale_for(Some("bbb")));
7414        // No local HEAD (unborn): nothing to compare against, so not stale.
7415        assert!(!badge.is_stale_for(None));
7416    }
7417
7418    #[test]
7419    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7420        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
7421        // it — so asking would return exactly the cached verdict, and the badge
7422        // stays correctly stale via `is_stale_for` with no network. So a snapshot
7423        // that differs *only* in `head_sha` must compare **equal** as a watch.
7424        let snap = |sha: &str| {
7425            json!({"repos":[{
7426                "main_repo":"omni-dev",
7427                "github":{"owner":"rust-works","name":"omni-dev"},
7428                "root":"/r",
7429                "polling_enabled":true,
7430                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7431            }]})
7432        };
7433        let before = pr_watch_from_snapshot(&snap("aaa"));
7434        let after = pr_watch_from_snapshot(&snap("bbb"));
7435        assert_eq!(before.len(), 1);
7436        assert_eq!(before[0].target, after[0].target);
7437        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
7438        assert_eq!(before, after);
7439        assert!(!pr_watch_grew(&before, &after));
7440    }
7441
7442    #[test]
7443    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7444        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
7445        // it moves no local head — so an upstream move alone must register as "go
7446        // and ask now", or the badge sits at `●` until the backoff elapses.
7447        let snap = |upstream: &str| {
7448            json!({"repos":[{
7449                "main_repo":"omni-dev",
7450                "github":{"owner":"rust-works","name":"omni-dev"},
7451                "root":"/r",
7452                "polling_enabled":true,
7453                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7454                              "upstream_sha":upstream,"is_main":true,"open":true}]
7455            }]})
7456        };
7457        let before = pr_watch_from_snapshot(&snap("aaa"));
7458        let after = pr_watch_from_snapshot(&snap("bbb"));
7459        // Same target, only the upstream moved — a genuine "grew" signal.
7460        assert_eq!(before.len(), 1);
7461        assert_eq!(before[0].target, after[0].target);
7462        assert_ne!(before, after);
7463        assert!(pr_watch_grew(&before, &after));
7464        // A quiet tick still asks nothing.
7465        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
7466        assert!(!pr_watch_grew(
7467            &before,
7468            &pr_watch_from_snapshot(&snap("aaa"))
7469        ));
7470    }
7471
7472    #[test]
7473    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
7474        // An older daemon — or any branch tracking nothing — simply sends no
7475        // `upstream_sha`, which reads as `None` rather than failing the poll.
7476        let snap = json!({"repos":[{
7477            "main_repo":"omni-dev",
7478            "github":{"owner":"rust-works","name":"omni-dev"},
7479            "root":"/r",
7480            "polling_enabled":true,
7481            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
7482        }]});
7483        let watch = pr_watch_from_snapshot(&snap);
7484        assert_eq!(watch.len(), 1);
7485        assert_eq!(watch[0].upstream_sha, None);
7486    }
7487
7488    #[test]
7489    fn start_pr_poller_is_a_noop_outside_a_runtime() {
7490        let svc = WorktreesService::new();
7491        svc.start_pr_poller();
7492        assert!(svc
7493            .poller
7494            .lock()
7495            .unwrap_or_else(PoisonError::into_inner)
7496            .is_none());
7497    }
7498
7499    #[tokio::test]
7500    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
7501        let svc = WorktreesService::new();
7502        svc.start_pr_poller_with(
7503            Duration::from_millis(50),
7504            Duration::from_millis(10),
7505            PathBuf::from("/bin/true"),
7506        );
7507        let token = svc
7508            .poller
7509            .lock()
7510            .unwrap_or_else(PoisonError::into_inner)
7511            .as_ref()
7512            .map(|t| t.token.clone())
7513            .expect("poller started");
7514
7515        // Cancel the live task, then start again: if `start` spawned a replacement
7516        // it would orphan this one, so the token staying cancelled proves it did not.
7517        token.cancel();
7518        svc.start_pr_poller_with(
7519            Duration::from_millis(50),
7520            Duration::from_millis(10),
7521            PathBuf::from("/bin/true"),
7522        );
7523        assert!(svc
7524            .poller
7525            .lock()
7526            .unwrap_or_else(PoisonError::into_inner)
7527            .as_ref()
7528            .is_some_and(|t| t.token.is_cancelled()));
7529
7530        svc.shutdown().await;
7531        assert!(svc
7532            .poller
7533            .lock()
7534            .unwrap_or_else(PoisonError::into_inner)
7535            .is_none());
7536    }
7537
7538    // --- Rate-limit monitor (#1375) ---
7539
7540    /// A resource at `used`% of a 100-request budget.
7541    fn rl_resource(used: u64) -> RateLimitResource {
7542        RateLimitResource {
7543            used,
7544            limit: 100,
7545            remaining: 100 - used,
7546            percent: used as f64,
7547            reset: 0,
7548        }
7549    }
7550
7551    #[test]
7552    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
7553        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
7554            graphql: Some(rl_resource(graphql)),
7555            core: Some(rl_resource(core)),
7556            search: None,
7557        };
7558        // First poll already over threshold → warn.
7559        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
7560        // First poll below → no warn.
7561        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
7562        // Crossing upward → warn.
7563        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
7564        // Staying over → no repeat warn.
7565        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
7566        // Recovering below → no warn.
7567        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
7568        // A *different* resource crossing while the first recovers is still caught.
7569        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
7570    }
7571
7572    #[test]
7573    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
7574        let svc = WorktreesService::new();
7575        svc.start_rate_limit_poller();
7576        assert!(svc
7577            .rate_limit_poller
7578            .lock()
7579            .unwrap_or_else(PoisonError::into_inner)
7580            .is_none());
7581    }
7582
7583    #[tokio::test]
7584    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
7585        let svc = WorktreesService::new();
7586        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
7587        let token = svc
7588            .rate_limit_poller
7589            .lock()
7590            .unwrap_or_else(PoisonError::into_inner)
7591            .as_ref()
7592            .map(|t| t.token.clone())
7593            .expect("poller started");
7594
7595        // Cancel the live task, then start again: a second start must not spawn a
7596        // replacement (which would orphan this one), so the token stays cancelled.
7597        token.cancel();
7598        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
7599        assert!(svc
7600            .rate_limit_poller
7601            .lock()
7602            .unwrap_or_else(PoisonError::into_inner)
7603            .as_ref()
7604            .is_some_and(|t| t.token.is_cancelled()));
7605
7606        svc.shutdown().await;
7607        assert!(svc
7608            .rate_limit_poller
7609            .lock()
7610            .unwrap_or_else(PoisonError::into_inner)
7611            .is_none());
7612    }
7613
7614    #[tokio::test]
7615    // Holds the shim guard across awaits; see the note above.
7616    #[allow(clippy::await_holding_lock)]
7617    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
7618        let bin_dir = tempfile::tempdir().unwrap();
7619        let (fake, _shim) = fake_gh(
7620            bin_dir.path(),
7621            r#"{"resources":{
7622                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
7623                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
7624            }}"#,
7625        );
7626        let svc = WorktreesService::new();
7627        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
7628        // is being watched — a lease makes it active without needing a window/folder.
7629        svc.registry.set_polling("rust-works", "omni-dev", true);
7630        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
7631
7632        // Each poll spawns a real subprocess; wait on a generous deadline so a
7633        // loaded machine fails honestly rather than flaking.
7634        let snap = tokio::time::timeout(Duration::from_secs(30), async {
7635            loop {
7636                if let Some(snap) = svc.rate_limit_cache.get() {
7637                    return snap;
7638                }
7639                tokio::time::sleep(Duration::from_millis(25)).await;
7640            }
7641        })
7642        .await
7643        .expect("poller should populate the cache through the fake gh");
7644        assert_eq!(snap.graphql.unwrap().used, 4100);
7645        assert_eq!(snap.core.unwrap().used, 27);
7646
7647        // The reading reaches the built-in status field via the shared cache.
7648        assert!(svc.rate_limit_cache().get().is_some());
7649
7650        svc.shutdown().await;
7651        assert!(svc
7652            .rate_limit_poller
7653            .lock()
7654            .unwrap_or_else(PoisonError::into_inner)
7655            .is_none());
7656    }
7657
7658    #[tokio::test]
7659    // Holds the shim guard across awaits; see the note above.
7660    #[allow(clippy::await_holding_lock)]
7661    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
7662        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
7663        // `/rate_limit` subprocess — the counting stub records zero spawns.
7664        let bin_dir = tempfile::tempdir().unwrap();
7665        let (fake, _shim, counter) = counting_fake_gh(
7666            bin_dir.path(),
7667            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
7668        );
7669        let svc = WorktreesService::new();
7670        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
7671
7672        // Give the loop several ticks; with nothing registered it must never poll.
7673        tokio::time::sleep(Duration::from_millis(300)).await;
7674        assert_eq!(
7675            gh_spawn_count(&counter),
7676            0,
7677            "idle daemon must not poll (#1389, fix 8b)"
7678        );
7679        assert!(svc.rate_limit_cache.get().is_none());
7680
7681        // Once a lease is active, the next tick populates the cache.
7682        svc.registry.set_polling("rust-works", "omni-dev", true);
7683        tokio::time::timeout(Duration::from_secs(30), async {
7684            loop {
7685                if svc.rate_limit_cache.get().is_some() {
7686                    return;
7687                }
7688                tokio::time::sleep(Duration::from_millis(25)).await;
7689            }
7690        })
7691        .await
7692        .expect("an active lease should resume polling");
7693        assert!(gh_spawn_count(&counter) >= 1);
7694        svc.shutdown().await;
7695    }
7696
7697    #[tokio::test]
7698    async fn rate_limit_poller_survives_a_failing_gh() {
7699        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
7700        // the degraded branch keeps the last (here, absent) reading rather than
7701        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
7702        let svc = WorktreesService::new();
7703        svc.registry.set_polling("rust-works", "omni-dev", true);
7704        svc.start_rate_limit_poller_with(
7705            Duration::from_millis(20),
7706            PathBuf::from("/no/such/gh/xyzzy"),
7707        );
7708        // Let it fail a few times: the cache stays empty and the task stays alive.
7709        tokio::time::sleep(Duration::from_millis(150)).await;
7710        assert!(svc.rate_limit_cache.get().is_none());
7711        assert!(
7712            svc.rate_limit_poller
7713                .lock()
7714                .unwrap_or_else(PoisonError::into_inner)
7715                .is_some(),
7716            "the loop must survive a failing gh, not panic out"
7717        );
7718        svc.shutdown().await;
7719    }
7720
7721    #[test]
7722    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
7723        let svc = WorktreesService::new();
7724        // Empty cache → no rate-limit line (the pre-#1375 shape).
7725        let items = svc.menu().items;
7726        assert!(
7727            !items
7728                .iter()
7729                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
7730            "no github line before the first poll"
7731        );
7732
7733        // Populate the cache → the first item is the rate-limit status line.
7734        svc.rate_limit_cache.replace(RateLimitSnapshot {
7735            graphql: Some(rl_resource(82)),
7736            core: Some(rl_resource(3)),
7737            search: None,
7738        });
7739        let items = svc.menu().items;
7740        assert!(
7741            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
7742            "expected the github line first, got {items:?}"
7743        );
7744        assert!(
7745            matches!(items.get(1), Some(MenuItem::Separator)),
7746            "expected a separator after the github line"
7747        );
7748    }
7749
7750    #[tokio::test]
7751    // Holds the shim guard across awaits; see the note above.
7752    #[allow(clippy::await_holding_lock)]
7753    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
7754        let dir = tempfile::tempdir().unwrap();
7755        github_repo(dir.path());
7756        let bin_dir = tempfile::tempdir().unwrap();
7757        // One repo, one branch → aliases r0/b0. A still-running check so the badge
7758        // stays pending and the loop keeps its fast cadence.
7759        let (fake, _shim) = fake_gh(
7760            bin_dir.path(),
7761            r#"{"data":{"r0":{"b0":{
7762                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7763                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7764                ]}}},
7765                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7766            }}}}"#,
7767        );
7768        let svc = WorktreesService::new();
7769        svc.handle(
7770            "register",
7771            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7772        )
7773        .await
7774        .unwrap();
7775        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7776        // act on it, as if the user had toggled it on.
7777        svc.registry.set_polling("rust-works", "omni-dev", true);
7778        svc.start_pr_poller_with(
7779            Duration::from_millis(50),
7780            Duration::from_millis(10),
7781            fake.clone(),
7782        );
7783
7784        // Wait on a generous wall-clock deadline: each poll spawns a real
7785        // subprocess, and under a loaded machine (a full `build.sh` runs a build
7786        // and clippy alongside) a tight budget flakes rather than fails honestly.
7787        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7788            loop {
7789                if let Some(PrResolution::Pr(badge)) =
7790                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7791                {
7792                    return badge;
7793                }
7794                tokio::time::sleep(Duration::from_millis(25)).await;
7795            }
7796        })
7797        .await
7798        .expect("poller should resolve a badge through the fake gh");
7799        assert_eq!(badge.number, 1337);
7800        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
7801
7802        // The badge reaches the wire the windows actually read.
7803        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7804        assert_eq!(wt["pr"]["number"], json!(1337));
7805
7806        // And the loop is quiescent after shutdown: the generation must stop moving.
7807        svc.shutdown().await;
7808        let generation = svc.registry.change_generation();
7809        tokio::time::sleep(Duration::from_millis(120)).await;
7810        assert_eq!(
7811            svc.registry.change_generation(),
7812            generation,
7813            "no bumps after shutdown"
7814        );
7815    }
7816
7817    #[tokio::test]
7818    // Holds the shim guard across awaits; see the note above.
7819    #[allow(clippy::await_holding_lock)]
7820    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
7821        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
7822        // the poller folds into the shared cache — so the graphql figure stays fresh
7823        // without a standalone `/rate_limit` call.
7824        let dir = tempfile::tempdir().unwrap();
7825        github_repo(dir.path());
7826        let bin_dir = tempfile::tempdir().unwrap();
7827        let (fake, _shim) = fake_gh(
7828            bin_dir.path(),
7829            r#"{"data":{
7830                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
7831                             "resetAt":"2026-07-21T16:00:00Z"},
7832                "r0":{"b0":{
7833                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7834                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7835                  ]}}},
7836                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7837                }}
7838            }}"#,
7839        );
7840        let svc = WorktreesService::new();
7841        svc.handle(
7842            "register",
7843            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7844        )
7845        .await
7846        .unwrap();
7847        svc.registry.set_polling("rust-works", "omni-dev", true);
7848        // No rate-limit poller started: the only writer of the cache is the PR poll's
7849        // folded-in budget, so a populated graphql reading proves fix 8a.
7850        svc.start_pr_poller_with(
7851            Duration::from_millis(50),
7852            Duration::from_millis(10),
7853            fake.clone(),
7854        );
7855
7856        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
7857            loop {
7858                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
7859                    return g;
7860                }
7861                tokio::time::sleep(Duration::from_millis(25)).await;
7862            }
7863        })
7864        .await
7865        .expect("the PR poll should fold its budget into the cache");
7866        assert_eq!(graphql.used, 123);
7867        assert_eq!(graphql.limit, 5000);
7868        assert_eq!(graphql.remaining, 4877);
7869        svc.shutdown().await;
7870    }
7871
7872    #[tokio::test]
7873    // Holds the shim guard across awaits; see the note above.
7874    #[allow(clippy::await_holding_lock)]
7875    async fn pr_poll_counts_every_gh_call_exactly_once() {
7876        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
7877        // still counted. Compares the ground-truth number of `gh` subprocesses the
7878        // poll actually spawned against the number of successful `kind:"gh"` records
7879        // the counted `run_gh` choke point wrote — they must be equal, so a future
7880        // refactor cannot add an uncounted `gh` path (counted < spawns) without
7881        // failing here.
7882        let dir = tempfile::tempdir().unwrap();
7883        github_repo(dir.path());
7884        let bin_dir = tempfile::tempdir().unwrap();
7885        let (fake, _shim, counter) = counting_fake_gh(
7886            bin_dir.path(),
7887            r#"{"data":{"r0":{"b0":{
7888                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
7889                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7890                ]}}},
7891                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
7892            }}}}"#,
7893        );
7894        let log = bin_dir.path().join("log.jsonl");
7895        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
7896
7897        let svc = WorktreesService::new();
7898        svc.handle(
7899            "register",
7900            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7901        )
7902        .await
7903        .unwrap();
7904        svc.registry.set_polling("rust-works", "omni-dev", true);
7905        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
7906
7907        // Wait for at least one fetch to land.
7908        tokio::time::timeout(Duration::from_secs(30), async {
7909            loop {
7910                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
7911                    return;
7912                }
7913                tokio::time::sleep(Duration::from_millis(25)).await;
7914            }
7915        })
7916        .await
7917        .expect("poller should fetch through the fake gh");
7918
7919        // Stop the loop so both counts are final (no in-flight gh), then compare.
7920        svc.shutdown().await;
7921        let spawns = gh_spawn_count(&counter);
7922        let counted = counted_gh_records(&log);
7923        std::env::remove_var("OMNI_DEV_LOG_FILE");
7924        assert!(
7925            spawns >= 1,
7926            "the poll should have spent at least one gh call"
7927        );
7928        assert_eq!(
7929            counted, spawns,
7930            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
7931        );
7932    }
7933
7934    #[tokio::test]
7935    // Holds the shim guard across awaits; see the note above.
7936    #[allow(clippy::await_holding_lock)]
7937    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
7938        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
7939        // its windows) that each *grow* the watch must collapse into ONE fetch on
7940        // the final set, not one per window. Two distinct repos appear back-to-back
7941        // inside the debounce window; a debounce-free loop would fetch twice.
7942        let dir_a = tempfile::tempdir().unwrap();
7943        let dir_b = tempfile::tempdir().unwrap();
7944        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
7945        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
7946        let bin_dir = tempfile::tempdir().unwrap();
7947        // Both terminal, so no fast pending cadence can add a second fetch.
7948        let (fake, _shim, counter) = counting_fake_gh(
7949            bin_dir.path(),
7950            r#"{"data":{
7951                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7952                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
7953                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
7954                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
7955                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
7956                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
7957            }}"#,
7958        );
7959        let svc = WorktreesService::new();
7960        // Enable polling for both before they register, so the first snapshot after
7961        // the storm already counts them.
7962        svc.registry.set_polling("rust-works", "omni-dev", true);
7963        svc.registry.set_polling("rust-works", "other-repo", true);
7964        // `base` far larger than the test so only the storm — never the cadence —
7965        // can trigger a fetch; a generous debounce so the two registers land inside
7966        // one settle window even on a loaded machine.
7967        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
7968        // The burst: both windows register back-to-back.
7969        svc.handle(
7970            "register",
7971            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
7972        )
7973        .await
7974        .unwrap();
7975        // A beat between the two, so the first bump has (all but certainly)
7976        // woken the poller into its settle window before the second arrives —
7977        // exercising the debounce *restart*, not just a single coalesced wake.
7978        tokio::time::sleep(Duration::from_millis(50)).await;
7979        svc.handle(
7980            "register",
7981            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
7982        )
7983        .await
7984        .unwrap();
7985
7986        // Wait until both badges resolve — proving the single fetch covered the full
7987        // final set, not just the first window.
7988        tokio::time::timeout(Duration::from_secs(30), async {
7989            loop {
7990                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
7991                let b = svc
7992                    .pr_cache
7993                    .get("rust-works", "other-repo", "main")
7994                    .is_some();
7995                if a && b {
7996                    return;
7997                }
7998                tokio::time::sleep(Duration::from_millis(25)).await;
7999            }
8000        })
8001        .await
8002        .expect("the debounced fetch should resolve both repos");
8003
8004        svc.shutdown().await;
8005        assert_eq!(
8006            gh_spawn_count(&counter),
8007            1,
8008            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8009        );
8010    }
8011
8012    #[tokio::test]
8013    // Holds the shim guard across awaits; see the note above.
8014    #[allow(clippy::await_holding_lock)]
8015    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8016        // The settle loop is bounded: a drip of registry bumps, each landing
8017        // inside the debounce window, must not postpone the poll forever — the
8018        // overall deadline (4× the debounce) forces the snapshot mid-storm
8019        // (#1389, fix 2).
8020        let dir = tempfile::tempdir().unwrap();
8021        github_repo(dir.path());
8022        let bin_dir = tempfile::tempdir().unwrap();
8023        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8024        let svc = WorktreesService::new();
8025        svc.registry.set_polling("rust-works", "omni-dev", true);
8026        // `base` far larger than the test, so only the grew-trigger — released
8027        // by the deadline — can fetch.
8028        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(50), fake);
8029        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8030        svc.handle("register", register.clone()).await.unwrap();
8031        // Re-register (an upsert, but still a bump) every 25ms — inside the
8032        // 50ms debounce — for well past the 200ms deadline. A deadline-free
8033        // loop would still be settling when the drip ends.
8034        for _ in 0..24 {
8035            tokio::time::sleep(Duration::from_millis(25)).await;
8036            svc.handle("register", register.clone()).await.unwrap();
8037        }
8038        let spawned_mid_drip = gh_spawn_count(&counter);
8039        svc.shutdown().await;
8040        assert!(
8041            spawned_mid_drip >= 1,
8042            "the deadline must force a fetch while the drip is still running (#1389, fix 2)"
8043        );
8044    }
8045
8046    #[tokio::test]
8047    // Holds the shim guard across awaits; see the note above.
8048    #[allow(clippy::await_holding_lock)]
8049    async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8050        // #1389, fix 4: a daemon restart within the backoff window serves badges
8051        // from the persisted cache and spends **no** gh call, because every current
8052        // target already has a fresh verdict.
8053        let dir = tempfile::tempdir().unwrap();
8054        let repo = github_repo(dir.path());
8055        let head = repo.head().unwrap().target().unwrap().to_string();
8056        let bin_dir = tempfile::tempdir().unwrap();
8057        // If the poller wrongly fetched, this empty reply would still spawn the stub
8058        // and bump the counter — which is exactly what the assertion catches.
8059        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8060
8061        // Persist a fresh cache the way the previous daemon would have: a badge for
8062        // `main` whose verdict is about the current head (so it is not stale),
8063        // watched at `(main, no upstream)`, resolved just now.
8064        let cache_path = bin_dir.path().join("pr-cache.json");
8065        let target = PrTarget {
8066            owner: "rust-works".into(),
8067            name: "omni-dev".into(),
8068            branch: "main".into(),
8069        };
8070        let prefs = pr_cache_prefs_from(
8071            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8072            &[watch("main", None)],
8073            Utc::now(),
8074        );
8075        write_pr_cache(&cache_path, &prefs).unwrap();
8076
8077        let svc = WorktreesService::new();
8078        svc.load_pr_cache(cache_path);
8079        svc.registry.set_polling("rust-works", "omni-dev", true);
8080        svc.handle(
8081            "register",
8082            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8083        )
8084        .await
8085        .unwrap();
8086        // `base` far larger than the test: the only fetch that could happen is the
8087        // immediate one we expect the warm cache to skip.
8088        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8089
8090        // The restored badge renders on the wire without a gh call.
8091        let number = tokio::time::timeout(Duration::from_secs(30), async {
8092            loop {
8093                let tree = svc.handle("tree", Value::Null).await.unwrap();
8094                if let Some(n) = repos_of(&tree)
8095                    .first()
8096                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8097                {
8098                    return n;
8099                }
8100                tokio::time::sleep(Duration::from_millis(25)).await;
8101            }
8102        })
8103        .await
8104        .expect("the restored badge should render from the warm cache");
8105        assert_eq!(number, 1337);
8106
8107        // Let the poller run a while, then confirm it stayed quiet.
8108        tokio::time::sleep(Duration::from_millis(300)).await;
8109        svc.shutdown().await;
8110        assert_eq!(
8111            gh_spawn_count(&counter),
8112            0,
8113            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8114        );
8115    }
8116
8117    #[tokio::test]
8118    // Holds the shim guard across awaits; see the note above.
8119    #[allow(clippy::await_holding_lock)]
8120    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8121        // #1389, fix 4, write side (the twin of the skip test above, which reads
8122        // a hand-written file): a successful resolve persists the cache —
8123        // creating the runtime dir if needed — so the *next* daemon restart
8124        // warm-starts from it.
8125        let dir = tempfile::tempdir().unwrap();
8126        github_repo(dir.path());
8127        let bin_dir = tempfile::tempdir().unwrap();
8128        let (fake, _shim) = fake_gh(
8129            bin_dir.path(),
8130            r#"{"data":{"r0":{"b0":{
8131                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8132                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8133                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8134            }}}}"#,
8135        );
8136        let svc = WorktreesService::new();
8137        // No file yet, and no parent dir either: the load takes the benign
8138        // NotFound arm, and the write must create the `0700` runtime dir.
8139        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8140        svc.load_pr_cache(cache_path.clone());
8141        svc.registry.set_polling("rust-works", "omni-dev", true);
8142        svc.handle(
8143            "register",
8144            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8145        )
8146        .await
8147        .unwrap();
8148        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8149
8150        // A partially-written or not-yet-written file simply retries: only a
8151        // fully parseable cache ends the wait.
8152        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8153            loop {
8154                if let Ok(bytes) = std::fs::read(&cache_path) {
8155                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8156                        if !prefs.entries.is_empty() {
8157                            return prefs;
8158                        }
8159                    }
8160                }
8161                tokio::time::sleep(Duration::from_millis(25)).await;
8162            }
8163        })
8164        .await
8165        .expect("a successful resolve should persist the cache file");
8166        svc.shutdown().await;
8167
8168        assert_eq!(prefs.entries[0].target.branch, "main");
8169        assert!(
8170            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8171            "{:?}",
8172            prefs.entries[0].resolution
8173        );
8174        assert_eq!(
8175            prefs.watched,
8176            vec![PersistedWatch {
8177                target: prefs.entries[0].target.clone(),
8178                upstream_sha: None
8179            }]
8180        );
8181        assert!(
8182            prefs.polled_at.is_some(),
8183            "the poll time is what ages the next warm start"
8184        );
8185    }
8186
8187    #[tokio::test]
8188    // Holds the shim guard across awaits; see the note above.
8189    #[allow(clippy::await_holding_lock)]
8190    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8191        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
8192        // to one counted `gh pr list` per repo. A generous TTL, so the second call
8193        // is served from the cache and spawns **no** second `gh` — the whole point.
8194        let bin_dir = tempfile::tempdir().unwrap();
8195        let (fake, _shim, counter) = counting_fake_gh(
8196            bin_dir.path(),
8197            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8198                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8199        );
8200        let svc = WorktreesService::new();
8201
8202        let prs = svc
8203            .open_prs_with("rust-works", "omni-dev", fake.clone())
8204            .await
8205            .expect("gh pr list should resolve");
8206        assert_eq!(prs.len(), 1);
8207        assert_eq!(prs[0]["number"], json!(42));
8208        assert_eq!(prs[0]["url"], json!("http://x/42"));
8209        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8210
8211        // A second window asking the same repo is served from the shared cache.
8212        let again = svc
8213            .open_prs_with("rust-works", "omni-dev", fake.clone())
8214            .await
8215            .expect("cache hit should resolve");
8216        assert_eq!(again, prs);
8217        assert_eq!(
8218            gh_spawn_count(&counter),
8219            1,
8220            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8221        );
8222
8223        // The op wrapper shapes the reply and validates the payload.
8224        let reply = svc
8225            .handle(
8226                "open-prs",
8227                json!({ "owner": "rust-works", "name": "omni-dev" }),
8228            )
8229            .await
8230            .expect("open-prs op should route");
8231        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8232        assert!(svc
8233            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
8234            .await
8235            .is_err());
8236    }
8237
8238    #[test]
8239    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8240        // The three degraded shapes a real `gh` presents — not installed, a
8241        // nonzero exit (auth/network), and output that is not the JSON array
8242        // the menu indexes into — must each be a distinct, actionable error
8243        // rather than a panic or a silently empty list (#1389, fix 7).
8244        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8245        assert!(
8246            err.to_string().contains("is the GitHub CLI installed"),
8247            "{err:#}"
8248        );
8249
8250        let bin_dir = tempfile::tempdir().unwrap();
8251        let _guard = shim_lock();
8252        let failing = bin_dir.path().join("fake-gh-fails");
8253        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8254        let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8255        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8256        assert!(err.to_string().contains("boom"), "{err:#}");
8257
8258        let object = bin_dir.path().join("fake-gh-object");
8259        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8260        let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8261        assert!(
8262            err.to_string().contains("did not return a JSON array"),
8263            "{err:#}"
8264        );
8265    }
8266
8267    #[tokio::test]
8268    // Holds the shim guard across awaits; see the note above.
8269    #[allow(clippy::await_holding_lock)]
8270    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8271        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
8272        // cadence and ignores even a grown watch, so no runaway can drain the shared
8273        // budget. A recent warm `last_poll` is seeded so the "first sight always
8274        // fetches" base case cannot mask the throttle — the only thing that could
8275        // fetch here is the grew-trigger, which the throttle suppresses.
8276        let dir = tempfile::tempdir().unwrap();
8277        github_repo(dir.path());
8278        let bin_dir = tempfile::tempdir().unwrap();
8279        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8280
8281        let svc = WorktreesService::new();
8282        // Warm start with an *empty* watch but a fresh poll time: the registered
8283        // repo then reads as a grown watch, while `last_poll` is recent enough that
8284        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
8285        *svc.pr_warm_start
8286            .lock()
8287            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8288            watched: vec![],
8289            polled_at: Utc::now(),
8290        });
8291        // Budget over the warn threshold before the poller starts.
8292        svc.rate_limit_cache.replace(RateLimitSnapshot {
8293            graphql: Some(rl_resource(90)),
8294            core: Some(rl_resource(3)),
8295            search: None,
8296        });
8297        svc.registry.set_polling("rust-works", "omni-dev", true);
8298        svc.handle(
8299            "register",
8300            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8301        )
8302        .await
8303        .unwrap();
8304        // `base` far larger than the test, so a fetch could only come from the
8305        // grew-trigger the throttle is meant to suppress.
8306        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8307
8308        // Let the poller wake on the registration and run a while.
8309        tokio::time::sleep(Duration::from_millis(300)).await;
8310        svc.shutdown().await;
8311        assert_eq!(
8312            gh_spawn_count(&counter),
8313            0,
8314            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8315        );
8316    }
8317
8318    #[tokio::test]
8319    // Holds the shim guard across awaits; see the note above.
8320    #[allow(clippy::await_holding_lock)]
8321    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8322        // The diff-and-drop contract: an unchanged poll must not bump, or every
8323        // window re-renders on every tick — the cost this design exists to avoid.
8324        let dir = tempfile::tempdir().unwrap();
8325        github_repo(dir.path());
8326        let bin_dir = tempfile::tempdir().unwrap();
8327        let (fake, _shim) = fake_gh(
8328            bin_dir.path(),
8329            r#"{"data":{"r0":{"b0":{
8330                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8331                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8332                ]}}},
8333                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8334            }}}}"#,
8335        );
8336        let svc = WorktreesService::new();
8337        svc.handle(
8338            "register",
8339            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8340        )
8341        .await
8342        .unwrap();
8343        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8344        // act on it, as if the user had toggled it on.
8345        svc.registry.set_polling("rust-works", "omni-dev", true);
8346        svc.start_pr_poller_with(
8347            Duration::from_millis(50),
8348            Duration::from_millis(10),
8349            fake.clone(),
8350        );
8351
8352        tokio::time::timeout(Duration::from_secs(30), async {
8353            loop {
8354                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8355                    return;
8356                }
8357                tokio::time::sleep(Duration::from_millis(25)).await;
8358            }
8359        })
8360        .await
8361        .expect("poller should resolve a badge through the fake gh");
8362        // The fake always answers identically, so after the first resolve every
8363        // subsequent poll is a no-change and must leave the generation alone.
8364        let settled = svc.registry.change_generation();
8365        tokio::time::sleep(Duration::from_millis(150)).await;
8366        assert_eq!(
8367            svc.registry.change_generation(),
8368            settled,
8369            "an unchanged poll must not bump the change-notify"
8370        );
8371        svc.shutdown().await;
8372    }
8373
8374    #[tokio::test]
8375    // Holds the shim guard across awaits; see the note above.
8376    #[allow(clippy::await_holding_lock)]
8377    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8378        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
8379        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
8380        // as `pr_none`, and — since the answer never changes — bumps the
8381        // change-notify only for the poll that first delivered it.
8382        let dir = tempfile::tempdir().unwrap();
8383        github_repo(dir.path());
8384        let bin_dir = tempfile::tempdir().unwrap();
8385        let (fake, _shim) = fake_gh(
8386            bin_dir.path(),
8387            r#"{"data":{"r0":{"b0":{
8388                "target":{"oid":"abc","statusCheckRollup":null},
8389                "associatedPullRequests":{"nodes":[]}
8390            }}}}"#,
8391        );
8392        let svc = WorktreesService::new();
8393        svc.handle(
8394            "register",
8395            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8396        )
8397        .await
8398        .unwrap();
8399        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8400        // act on it, as if the user had toggled it on.
8401        svc.registry.set_polling("rust-works", "omni-dev", true);
8402        svc.start_pr_poller_with(
8403            Duration::from_millis(50),
8404            Duration::from_millis(10),
8405            fake.clone(),
8406        );
8407
8408        tokio::time::timeout(Duration::from_secs(30), async {
8409            loop {
8410                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8411                    return;
8412                }
8413                tokio::time::sleep(Duration::from_millis(25)).await;
8414            }
8415        })
8416        .await
8417        .expect("poller should resolve the negative through the fake gh");
8418
8419        // The negative reaches the wire the windows actually read.
8420        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8421        assert_eq!(wt["pr_none"], json!(true));
8422        assert!(wt.get("pr").is_none(), "{wt:?}");
8423
8424        // Identical re-polls of the same negative must not bump.
8425        let settled = svc.registry.change_generation();
8426        tokio::time::sleep(Duration::from_millis(150)).await;
8427        assert_eq!(
8428            svc.registry.change_generation(),
8429            settled,
8430            "an unchanged negative must not bump the change-notify"
8431        );
8432        svc.shutdown().await;
8433    }
8434
8435    #[test]
8436    fn sync_indicator_formats_only_with_upstream() {
8437        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8438        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8439        assert_eq!(sync_indicator(None, None), None);
8440        // A partial pair (no real upstream) yields nothing.
8441        assert_eq!(sync_indicator(Some(1), None), None);
8442    }
8443
8444    #[tokio::test]
8445    async fn list_enriches_entries_with_git_status() {
8446        let dir = tempfile::tempdir().unwrap();
8447        let repo = init_repo(dir.path());
8448        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8449        repo.set_head("refs/heads/main").unwrap();
8450
8451        let svc = WorktreesService::new();
8452        svc.handle(
8453            "register",
8454            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
8455        )
8456        .await
8457        .unwrap();
8458        let payload = svc.handle("list", Value::Null).await.unwrap();
8459        let windows = windows_of(&payload);
8460        assert_eq!(windows.len(), 1);
8461        assert_eq!(
8462            windows[0].get("branch").and_then(Value::as_str),
8463            Some("main")
8464        );
8465        // No upstream configured → the ahead/behind keys are absent, not zero.
8466        assert!(windows[0].get("ahead").is_none());
8467        assert!(windows[0].get("behind").is_none());
8468        // The main repo name is enriched onto the entry.
8469        assert_eq!(
8470            windows[0].get("main_repo").and_then(Value::as_str),
8471            dir.path().file_name().and_then(|n| n.to_str())
8472        );
8473
8474        // A non-repo folder is still listed, just without a branch or main repo.
8475        let plain = tempfile::tempdir().unwrap();
8476        svc.handle(
8477            "register",
8478            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
8479        )
8480        .await
8481        .unwrap();
8482        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
8483        let w2 = windows
8484            .iter()
8485            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
8486            .unwrap();
8487        assert!(w2.get("branch").is_none());
8488        assert!(w2.get("main_repo").is_none());
8489    }
8490
8491    #[test]
8492    fn window_label_prefers_git_branch_over_title() {
8493        let dir = tempfile::tempdir().unwrap();
8494        let repo = init_repo(dir.path());
8495        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8496        repo.set_head("refs/heads/main").unwrap();
8497        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
8498        let entry = WindowEntry {
8499            key: "k".to_string(),
8500            folders: vec![dir.path().to_path_buf()],
8501            // Both the companion `repo` and `title` are overridden by the
8502            // git-derived main repo name and computed branch.
8503            repo: Some("companion-repo".to_string()),
8504            title: Some("ignored title".to_string()),
8505            pid: None,
8506            last_seen: Utc::now(),
8507        };
8508        // Main checkout: `repo · branch`, and with no upstream there is no sync.
8509        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
8510    }
8511
8512    #[tokio::test]
8513    async fn list_includes_ahead_behind_for_tracking_branch() {
8514        let dir = tempfile::tempdir().unwrap();
8515        let _repo = diverging_repo(dir.path());
8516
8517        let svc = WorktreesService::new();
8518        svc.handle(
8519            "register",
8520            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
8521        )
8522        .await
8523        .unwrap();
8524        let payload = svc.handle("list", Value::Null).await.unwrap();
8525        let windows = windows_of(&payload);
8526        // A tracking branch serializes branch plus both divergence counts.
8527        assert_eq!(
8528            windows[0].get("branch").and_then(Value::as_str),
8529            Some("main")
8530        );
8531        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
8532        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
8533    }
8534
8535    #[test]
8536    fn window_label_includes_sync_for_tracking_branch() {
8537        let dir = tempfile::tempdir().unwrap();
8538        let _repo = diverging_repo(dir.path());
8539        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
8540        let entry = WindowEntry {
8541            key: "k".to_string(),
8542            folders: vec![dir.path().to_path_buf()],
8543            repo: Some("companion-repo".to_string()),
8544            title: None,
8545            pid: None,
8546            last_seen: Utc::now(),
8547        };
8548        // A tracking branch appends the `(+ahead -behind)` sync indicator.
8549        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
8550    }
8551
8552    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
8553    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
8554    /// <wt_path>`.
8555    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
8556        let commit = repo.find_commit(base).unwrap();
8557        repo.branch(branch, &commit, false).unwrap();
8558        let reference = repo
8559            .find_reference(&format!("refs/heads/{branch}"))
8560            .unwrap();
8561        let mut opts = git2::WorktreeAddOptions::new();
8562        opts.reference(Some(&reference));
8563        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
8564    }
8565
8566    #[test]
8567    fn git_status_marks_linked_worktree_and_names_parent_repo() {
8568        let main_dir = tempfile::tempdir().unwrap();
8569        let repo = init_repo(main_dir.path());
8570        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8571        repo.set_head("refs/heads/main").unwrap();
8572
8573        // A linked worktree checked out on a new `feature` branch, in a
8574        // directory whose basename is deliberately *not* the repo name.
8575        let wt_parent = tempfile::tempdir().unwrap();
8576        let wt_path = wt_parent.path().join("feature-wt");
8577        add_worktree(&repo, a, &wt_path, "feature");
8578
8579        let status = git_status(&wt_path);
8580        assert!(status.is_worktree);
8581        assert_eq!(status.branch.as_deref(), Some("feature"));
8582        // The worktree names its *parent* repo, not its worktree-folder basename.
8583        assert_eq!(
8584            status.main_repo.as_deref(),
8585            main_dir.path().file_name().and_then(|n| n.to_str())
8586        );
8587
8588        // The main checkout resolves the same repo name and is not a worktree.
8589        let main_status = git_status(main_dir.path());
8590        assert!(!main_status.is_worktree);
8591        assert_eq!(main_status.main_repo, status.main_repo);
8592    }
8593
8594    #[test]
8595    fn window_label_marks_worktree_with_fork_glyph() {
8596        let main_dir = tempfile::tempdir().unwrap();
8597        let repo = init_repo(main_dir.path());
8598        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8599        repo.set_head("refs/heads/main").unwrap();
8600        let wt_parent = tempfile::tempdir().unwrap();
8601        let wt_path = wt_parent.path().join("feature-wt");
8602        add_worktree(&repo, a, &wt_path, "feature");
8603
8604        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
8605        let entry = WindowEntry {
8606            key: "k".to_string(),
8607            folders: vec![wt_path],
8608            repo: Some("feature-wt".to_string()),
8609            title: None,
8610            pid: None,
8611            last_seen: Utc::now(),
8612        };
8613        // A worktree line: parent repo, the fork glyph, then the branch (no
8614        // upstream here, so no sync suffix).
8615        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
8616    }
8617
8618    #[test]
8619    fn main_repo_name_derives_from_common_dir() {
8620        // Normal layout: the repo is the directory that contains `.git`.
8621        assert_eq!(
8622            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
8623            Some("omni-dev")
8624        );
8625        // A trailing slash on the common dir does not change the answer.
8626        assert_eq!(
8627            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
8628            Some("omni-dev")
8629        );
8630        // A bare repo: its own directory name, without the `.git` suffix.
8631        assert_eq!(
8632            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
8633            Some("omni-dev")
8634        );
8635        // A `.git` at the filesystem root has no parent name to use.
8636        assert_eq!(main_repo_name(Path::new("/.git")), None);
8637    }
8638
8639    // --- Repo/worktree tree (#1265) ----------------------------------------
8640
8641    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
8642    /// temporary payload).
8643    fn repos_of(payload: &Value) -> Vec<Value> {
8644        payload
8645            .get("repos")
8646            .and_then(Value::as_array)
8647            .expect("repos array")
8648            .clone()
8649    }
8650
8651    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
8652        Some(GithubIdentity {
8653            owner: owner.to_string(),
8654            name: name.to_string(),
8655        })
8656    }
8657
8658    #[test]
8659    fn github_identity_parses_supported_forms() {
8660        // https / http, with and without the `.git` suffix.
8661        assert_eq!(
8662            github_identity("https://github.com/rust-works/omni-dev.git"),
8663            github("rust-works", "omni-dev")
8664        );
8665        assert_eq!(
8666            github_identity("https://github.com/rust-works/omni-dev"),
8667            github("rust-works", "omni-dev")
8668        );
8669        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
8670        // SCP-like and ssh:// / git:// forms.
8671        assert_eq!(
8672            github_identity("git@github.com:rust-works/omni-dev.git"),
8673            github("rust-works", "omni-dev")
8674        );
8675        assert_eq!(
8676            github_identity("ssh://git@github.com/o/r.git"),
8677            github("o", "r")
8678        );
8679        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
8680        // A trailing slash and surrounding whitespace are tolerated.
8681        assert_eq!(
8682            github_identity("  https://github.com/o/r/  "),
8683            github("o", "r")
8684        );
8685    }
8686
8687    #[test]
8688    fn github_identity_rejects_non_github_and_malformed() {
8689        // Non-GitHub hosts.
8690        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
8691        assert_eq!(github_identity("git@example.com:o/r.git"), None);
8692        // Missing or extra path segments.
8693        assert_eq!(github_identity("https://github.com/onlyowner"), None);
8694        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
8695        assert_eq!(github_identity("https://github.com/"), None);
8696        // Not a URL at all.
8697        assert_eq!(github_identity("not a url"), None);
8698    }
8699
8700    #[test]
8701    fn remote_github_identity_reads_origin_then_falls_back() {
8702        let dir = tempfile::tempdir().unwrap();
8703        let repo = init_repo(dir.path());
8704        // No remotes → None.
8705        assert_eq!(remote_github_identity(&repo), None);
8706        // A non-GitHub origin is not a match.
8707        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
8708        assert_eq!(remote_github_identity(&repo), None);
8709        // A GitHub origin resolves to its identity.
8710        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
8711            .unwrap();
8712        assert_eq!(
8713            remote_github_identity(&repo),
8714            github("rust-works", "omni-dev")
8715        );
8716
8717        // Origin non-GitHub but another remote is GitHub: the fallback loop over
8718        // the remaining remotes finds it.
8719        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
8720            .unwrap();
8721        repo.remote("upstream", "https://github.com/other/proj.git")
8722            .unwrap();
8723        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
8724    }
8725
8726    #[tokio::test]
8727    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
8728        let svc = WorktreesService::new();
8729        // No windows → an empty repo set (not an error), toggle at its default.
8730        assert_eq!(
8731            svc.handle("tree", Value::Null).await.unwrap(),
8732            json!({ "repos": [], "show_closed": true })
8733        );
8734        // A plain non-repo folder is skipped rather than sinking the op.
8735        let plain = tempfile::tempdir().unwrap();
8736        svc.handle(
8737            "register",
8738            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
8739        )
8740        .await
8741        .unwrap();
8742        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
8743    }
8744
8745    #[tokio::test]
8746    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
8747        let main_dir = tempfile::tempdir().unwrap();
8748        let repo = init_repo(main_dir.path());
8749        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8750        repo.set_head("refs/heads/main").unwrap();
8751        // A GitHub origin so the repo carries an identity in the payload.
8752        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
8753            .unwrap();
8754
8755        // A linked worktree on a new `feature` branch, in a directory whose
8756        // basename is deliberately not the repo name.
8757        let wt_parent = tempfile::tempdir().unwrap();
8758        let wt_path = wt_parent.path().join("feature-wt");
8759        add_worktree(&repo, a, &wt_path, "feature");
8760
8761        let svc = WorktreesService::new();
8762        // A window open on the main checkout and one on the linked worktree —
8763        // two windows, but one repo (they must dedupe).
8764        svc.handle(
8765            "register",
8766            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
8767        )
8768        .await
8769        .unwrap();
8770        svc.handle(
8771            "register",
8772            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
8773        )
8774        .await
8775        .unwrap();
8776
8777        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
8778        assert_eq!(
8779            repos.len(),
8780            1,
8781            "two worktrees of one repo dedupe: {repos:?}"
8782        );
8783        let repo0 = &repos[0];
8784        // Repo identity is the parent-repo name (not a worktree-folder basename).
8785        assert_eq!(
8786            repo0.get("main_repo").and_then(Value::as_str),
8787            main_dir.path().file_name().and_then(|n| n.to_str())
8788        );
8789        assert_eq!(
8790            repo0.pointer("/github/owner").and_then(Value::as_str),
8791            Some("rust-works")
8792        );
8793        assert_eq!(
8794            repo0.pointer("/github/name").and_then(Value::as_str),
8795            Some("omni-dev")
8796        );
8797        assert!(repo0.get("root").and_then(Value::as_str).is_some());
8798
8799        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
8800        assert_eq!(worktrees.len(), 2);
8801        // Main working tree first: is_main, open, with the main window's key.
8802        let main_wt = &worktrees[0];
8803        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
8804        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
8805        assert_eq!(
8806            main_wt.get("window_key").and_then(Value::as_str),
8807            Some("wm")
8808        );
8809        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
8810        // Linked worktree: not main, open via the feature window.
8811        let linked = &worktrees[1];
8812        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
8813        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
8814        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
8815        assert_eq!(
8816            linked.get("branch").and_then(Value::as_str),
8817            Some("feature")
8818        );
8819    }
8820
8821    #[tokio::test]
8822    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
8823        let main_dir = tempfile::tempdir().unwrap();
8824        let repo = init_repo(main_dir.path());
8825        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8826        repo.set_head("refs/heads/main").unwrap();
8827        // No remote at all → the repo carries no `github` identity.
8828        let wt_parent = tempfile::tempdir().unwrap();
8829        let wt_path = wt_parent.path().join("feature-wt");
8830        add_worktree(&repo, a, &wt_path, "feature");
8831
8832        let svc = WorktreesService::new();
8833        // Only the main checkout has a window; the linked worktree has none.
8834        svc.handle(
8835            "register",
8836            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
8837        )
8838        .await
8839        .unwrap();
8840
8841        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
8842        assert_eq!(repos.len(), 1);
8843        assert!(repos[0].get("github").is_none(), "no remote → no github");
8844        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
8845        let linked = worktrees
8846            .iter()
8847            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
8848            .expect("the linked worktree");
8849        // Enumerated even though no window has it open, and marked closed.
8850        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
8851        assert!(linked.get("window_key").is_none());
8852    }
8853
8854    // --- Close op (#1277) --------------------------------------------------
8855
8856    /// Builds a repo whose main working tree is on `trunk` with one **clean**
8857    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
8858    /// paths stay valid) and the linked worktree path.
8859    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
8860        let main_dir = tempfile::tempdir().unwrap();
8861        let repo = init_repo(main_dir.path());
8862        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
8863        repo.set_head("refs/heads/trunk").unwrap();
8864        let wt_parent = tempfile::tempdir().unwrap();
8865        let wt_path = wt_parent.path().join("feature-wt");
8866        add_worktree(&repo, a, &wt_path, "feature");
8867        (main_dir, wt_parent, wt_path)
8868    }
8869
8870    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
8871    /// repo — the shape a multi-select delete fans out over, and the only one where
8872    /// two prunes share a `.git/worktrees` to race on (#1359).
8873    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
8874    {
8875        let main_dir = tempfile::tempdir().unwrap();
8876        let repo = init_repo(main_dir.path());
8877        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
8878        repo.set_head("refs/heads/trunk").unwrap();
8879        let wt_parent = tempfile::tempdir().unwrap();
8880        let first = wt_parent.path().join("first-wt");
8881        let second = wt_parent.path().join("second-wt");
8882        add_worktree(&repo, a, &first, "first");
8883        add_worktree(&repo, a, &second, "second");
8884        (main_dir, wt_parent, first, second)
8885    }
8886
8887    #[tokio::test]
8888    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
8889        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
8890        let svc = Arc::new(WorktreesService::new());
8891
8892        // The multi-select fan-out: one `close` op per target, both in flight at
8893        // once against the one repo's shared admin state. Genuinely concurrent
8894        // even on this single-threaded runtime — each op's prune is a
8895        // `spawn_blocking`, so awaiting its join yields to the other op.
8896        //
8897        // This guards the fan-out end-to-end (both ops complete, neither is
8898        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
8899        // a race detector for the lock, because it is not one — it passes with the
8900        // guard removed, the two prunes being far too quick to collide reliably.
8901        let close = |path: PathBuf| {
8902            let svc = svc.clone();
8903            async move {
8904                svc.handle(
8905                    "close",
8906                    json!({ "path": path, "remove": true, "confirmed": true }),
8907                )
8908                .await
8909            }
8910        };
8911        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
8912
8913        assert_eq!(a.unwrap(), json!({ "removed": true }));
8914        assert_eq!(b.unwrap(), json!({ "removed": true }));
8915        assert!(!first.exists());
8916        assert!(!second.exists());
8917        // Both *admin* entries pruned too, not merely the directories — the half
8918        // the two ops contend on.
8919        let repo = Repository::open(main_dir.path()).unwrap();
8920        assert!(repo.worktrees().unwrap().is_empty());
8921    }
8922
8923    // --- Merge-queue op (#1401) --------------------------------------------
8924
8925    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
8926    /// upstream points at the **same** commit (nothing to push) and a github
8927    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
8928    /// empty (clean) working directory.
8929    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
8930        let repo = init_repo(dir);
8931        let refname = format!("refs/heads/{branch}");
8932        let head = empty_commit(&repo, Some(&refname), &[], "A");
8933        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
8934            .unwrap();
8935        repo.set_head(&refname).unwrap();
8936        let mut cfg = repo.config().unwrap();
8937        cfg.set_str("remote.origin.url", url).unwrap();
8938        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
8939            .unwrap();
8940        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
8941            .unwrap();
8942        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
8943            .unwrap();
8944        repo
8945    }
8946
8947    #[test]
8948    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
8949        let dir = tempfile::tempdir().unwrap();
8950        let _repo = pushed_github_repo(
8951            dir.path(),
8952            "https://github.com/rust-works/omni-dev.git",
8953            "feature",
8954        );
8955        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
8956        assert_eq!(
8957            ok.target,
8958            PrTarget {
8959                owner: "rust-works".into(),
8960                name: "omni-dev".into(),
8961                branch: "feature".into(),
8962            }
8963        );
8964        assert!(!ok.head_sha.is_empty());
8965    }
8966
8967    #[test]
8968    fn evaluate_local_skips_an_unborn_head() {
8969        let dir = tempfile::tempdir().unwrap();
8970        let _repo = init_repo(dir.path()); // no commits
8971        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
8972    }
8973
8974    #[test]
8975    fn evaluate_local_skips_a_branch_with_no_upstream() {
8976        let dir = tempfile::tempdir().unwrap();
8977        let repo = init_repo(dir.path());
8978        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8979        repo.set_head("refs/heads/main").unwrap();
8980        // A github URL but no tracking config → nothing was ever pushed.
8981        repo.config()
8982            .unwrap()
8983            .set_str("remote.origin.url", "https://github.com/o/r.git")
8984            .unwrap();
8985        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
8986    }
8987
8988    #[test]
8989    fn evaluate_local_skips_unpushed_local_commits() {
8990        let dir = tempfile::tempdir().unwrap();
8991        let repo = init_repo(dir.path());
8992        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8993        let a_commit = repo.find_commit(a).unwrap();
8994        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
8995        repo.reference("refs/remotes/origin/main", a, true, "o")
8996            .unwrap();
8997        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
8998        drop(a_commit);
8999        repo.set_head("refs/heads/main").unwrap();
9000        let mut cfg = repo.config().unwrap();
9001        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9002            .unwrap();
9003        // The fetch refspec is what lets git2 map the branch to its tracking ref;
9004        // without it `upstream()` fails and the branch reads as having none.
9005        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9006            .unwrap();
9007        cfg.set_str("branch.main.remote", "origin").unwrap();
9008        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9009        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9010    }
9011
9012    #[test]
9013    fn evaluate_local_skips_a_detached_head() {
9014        let dir = tempfile::tempdir().unwrap();
9015        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9016        let head = repo.head().unwrap().target().unwrap();
9017        repo.set_head_detached(head).unwrap();
9018        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9019    }
9020
9021    #[test]
9022    fn evaluate_local_skips_a_non_github_remote() {
9023        let dir = tempfile::tempdir().unwrap();
9024        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9025        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9026    }
9027
9028    #[test]
9029    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9030        // A path git cannot discover a repo from is refused up front (defensive:
9031        // the UI only ever sends real worktrees). A nonexistent path is used so the
9032        // result never depends on whether the temp dir sits inside a checkout.
9033        assert_eq!(
9034            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9035                .unwrap_err()
9036                .kind,
9037            "not-a-repo"
9038        );
9039    }
9040
9041    #[test]
9042    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9043        // The audit line's `tracing` field expressions only evaluate when an INFO
9044        // subscriber is active — the sync helper makes that testable.
9045        let req = MergeQueueRequest {
9046            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9047            requester_key: Some("win-9".into()),
9048            check: true,
9049            confirmed: false,
9050        };
9051        let logs = capture_info(|| log_merge_check(&req, 1, 1));
9052        assert!(logs.contains("merge-queue check"), "{logs}");
9053        assert!(logs.contains("win-9"), "{logs}");
9054        assert!(logs.contains("requested=2"), "{logs}");
9055        assert!(logs.contains("eligible=1"), "{logs}");
9056    }
9057
9058    #[test]
9059    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9060        // A CLI-style requester (no window key) logs the dash fallback.
9061        let req = MergeQueueRequest {
9062            paths: vec![PathBuf::from("/a")],
9063            requester_key: None,
9064            check: false,
9065            confirmed: true,
9066        };
9067        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9068        assert!(logs.contains("merge-queue enqueue"), "{logs}");
9069        assert!(logs.contains("queued=2"), "{logs}");
9070        assert!(logs.contains("failed=1"), "{logs}");
9071    }
9072
9073    #[test]
9074    fn evaluate_local_flags_dirty_then_untracked() {
9075        // A linked worktree with a real checked-out file, so status is meaningful.
9076        let main_dir = tempfile::tempdir().unwrap();
9077        let repo = init_repo(main_dir.path());
9078        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9079        repo.set_head("refs/heads/main").unwrap();
9080        let wt_parent = tempfile::tempdir().unwrap();
9081        let wt_path = wt_parent.path().join("feature-wt");
9082        add_worktree(&repo, a, &wt_path, "feature");
9083
9084        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
9085        let clean = evaluate_local(&wt_path).unwrap_err();
9086        assert_ne!(clean.kind, "dirty");
9087        assert_ne!(clean.kind, "untracked");
9088
9089        // Modify the tracked file → dirty (gate 1, before any network call).
9090        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9091        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9092
9093        // Restore it, add a new file → untracked.
9094        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9095        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9096        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9097    }
9098
9099    #[test]
9100    fn is_conflicting_blocks_only_dirty_and_conflicting() {
9101        assert!(is_conflicting(Some("CONFLICTING")));
9102        assert!(is_conflicting(Some("DIRTY")));
9103        assert!(!is_conflicting(Some("CLEAN")));
9104        assert!(!is_conflicting(Some("BLOCKED")));
9105        assert!(!is_conflicting(Some("UNKNOWN")));
9106        assert!(!is_conflicting(None));
9107    }
9108
9109    #[test]
9110    fn merge_queue_request_parses_batch_and_phase_flags() {
9111        let req: MergeQueueRequest = serde_json::from_value(json!({
9112            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9113        }))
9114        .unwrap();
9115        assert_eq!(req.paths.len(), 2);
9116        assert_eq!(req.requester_key.as_deref(), Some("w1"));
9117        assert!(req.confirmed);
9118        assert!(!req.check);
9119        // Minimal payload: just paths; every other field defaults.
9120        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9121        assert!(req.paths.is_empty());
9122        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9123    }
9124
9125    #[test]
9126    fn queued_pr_omits_already_queued_when_false() {
9127        let v = serde_json::to_value(QueuedPr {
9128            path: "/a".into(),
9129            number: 5,
9130            already_queued: false,
9131        })
9132        .unwrap();
9133        assert!(v.get("already_queued").is_none(), "{v}");
9134        let v = serde_json::to_value(QueuedPr {
9135            path: "/a".into(),
9136            number: 5,
9137            already_queued: true,
9138        })
9139        .unwrap();
9140        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9141    }
9142
9143    #[tokio::test]
9144    async fn merge_queue_check_on_empty_selection_reports_nothing() {
9145        let svc = WorktreesService::new();
9146        let reply = svc
9147            .handle("merge-queue", json!({ "paths": [], "check": true }))
9148            .await
9149            .unwrap();
9150        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9151    }
9152
9153    #[tokio::test]
9154    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9155        // An unborn repo is skipped by the *local* gates, so the op never shells
9156        // `gh` — the check completes with no network stub.
9157        let dir = tempfile::tempdir().unwrap();
9158        let _repo = init_repo(dir.path());
9159        let svc = WorktreesService::new();
9160        let reply = svc
9161            .handle(
9162                "merge-queue",
9163                json!({ "paths": [dir.path()], "check": true }),
9164            )
9165            .await
9166            .unwrap();
9167        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9168        assert_eq!(skipped.len(), 1);
9169        assert_eq!(
9170            skipped[0].get("kind").and_then(Value::as_str),
9171            Some("no-commits")
9172        );
9173        assert!(reply
9174            .get("eligible")
9175            .and_then(Value::as_array)
9176            .unwrap()
9177            .is_empty());
9178    }
9179
9180    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
9181    /// that clears the local gates, so a test can drive the *network* gates by
9182    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
9183    fn ready_worktree() -> (tempfile::TempDir, String) {
9184        let dir = tempfile::tempdir().unwrap();
9185        let repo = pushed_github_repo(
9186            dir.path(),
9187            "https://github.com/rust-works/omni-dev.git",
9188            "feature",
9189        );
9190        let head = repo.head().unwrap().target().unwrap().to_string();
9191        (dir, head)
9192    }
9193
9194    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
9195    /// one check of `conclusion`, and one PR node `pr`.
9196    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9197        format!(
9198            r#"{{"data":{{"r0":{{"b0":{{
9199                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9200                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9201                ]}}}}}},
9202                "associatedPullRequests":{{"nodes":[{pr}]}}
9203            }}}}}}}}"#
9204        )
9205    }
9206
9207    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
9208    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
9209    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
9210    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9211        let ghdir = tempfile::tempdir().unwrap();
9212        let (bin, _shim) = fake_gh(ghdir.path(), reply);
9213        let paths = vec![head_dir.to_path_buf()];
9214        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9215        if let Some(e) = eligible.first() {
9216            return Ok(e.number);
9217        }
9218        Err(skipped.remove(0).kind)
9219    }
9220
9221    #[test]
9222    fn evaluate_batch_marks_a_ready_pr_eligible() {
9223        let (dir, head) = ready_worktree();
9224        let pr = format!(
9225            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9226        );
9227        assert_eq!(
9228            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9229            Ok(9)
9230        );
9231    }
9232
9233    #[test]
9234    fn evaluate_batch_skips_a_draft_pr() {
9235        let (dir, head) = ready_worktree();
9236        let pr = format!(
9237            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9238        );
9239        assert_eq!(
9240            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9241            Err("draft".to_string())
9242        );
9243    }
9244
9245    #[test]
9246    fn evaluate_batch_skips_a_conflicting_pr() {
9247        let (dir, head) = ready_worktree();
9248        let pr = format!(
9249            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9250        );
9251        assert_eq!(
9252            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9253            Err("conflicting".to_string())
9254        );
9255    }
9256
9257    #[test]
9258    fn evaluate_batch_skips_a_pr_with_failing_checks() {
9259        let (dir, head) = ready_worktree();
9260        let pr = format!(
9261            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9262        );
9263        assert_eq!(
9264            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9265            Err("checks-failing".to_string())
9266        );
9267    }
9268
9269    #[test]
9270    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9271        let (dir, head) = ready_worktree();
9272        // The remote PR head is a different commit than the local head.
9273        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9274        assert_eq!(
9275            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9276            Err("stale".to_string())
9277        );
9278    }
9279
9280    #[test]
9281    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9282        let (dir, head) = ready_worktree();
9283        // The ref resolves but no open PR heads it.
9284        let reply = format!(
9285            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9286        );
9287        assert_eq!(
9288            network_gate_outcome(dir.path(), &reply),
9289            Err("no-pr".to_string())
9290        );
9291    }
9292
9293    #[test]
9294    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9295        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
9296        // already-queued PR needs no mutation and is reported queued.
9297        let eligible = vec![
9298            Eligible {
9299                path: PathBuf::from("/wt/a"),
9300                number: 1,
9301                url: "u".into(),
9302                branch: "a".into(),
9303                pr_id: "PR_A".into(),
9304                already_queued: true,
9305            },
9306            Eligible {
9307                path: PathBuf::from("/wt/b"),
9308                number: 2,
9309                url: "u".into(),
9310                branch: "b".into(),
9311                pr_id: "PR_B".into(),
9312                already_queued: false,
9313            },
9314        ];
9315        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9316        assert_eq!(queued.len(), 1);
9317        assert_eq!(queued[0].number, 1);
9318        assert!(queued[0].already_queued);
9319        assert_eq!(failed.len(), 1);
9320        assert_eq!(failed[0].number, 2);
9321    }
9322
9323    #[test]
9324    fn enqueue_eligible_records_a_github_rejection_as_failed() {
9325        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
9326        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
9327        // rather than sinking the batch.
9328        let ghdir = tempfile::tempdir().unwrap();
9329        let (bin, _shim) = fake_gh(
9330            ghdir.path(),
9331            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9332        );
9333        let eligible = vec![Eligible {
9334            path: PathBuf::from("/wt/a"),
9335            number: 7,
9336            url: "u".into(),
9337            branch: "a".into(),
9338            pr_id: "PR_A".into(),
9339            already_queued: false,
9340        }];
9341        let (queued, failed) = enqueue_eligible(&bin, eligible);
9342        assert!(queued.is_empty(), "{queued:?}");
9343        assert_eq!(failed.len(), 1);
9344        assert_eq!(failed[0].number, 7);
9345        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
9346        // instead surface as an `Err`, still landing in `failed[]`).
9347        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9348    }
9349
9350    #[tokio::test]
9351    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9352    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9353        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
9354        // exercising the `PrRef` mapping the empty-selection tests cannot.
9355        let (dir, head) = ready_worktree();
9356        let pr = format!(
9357            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9358        );
9359        let ghdir = tempfile::tempdir().unwrap();
9360        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9361        let svc = WorktreesService::new();
9362        let reply = svc
9363            .merge_queue_with(
9364                MergeQueueRequest {
9365                    paths: vec![dir.path().to_path_buf()],
9366                    requester_key: None,
9367                    check: true,
9368                    confirmed: false,
9369                },
9370                bin,
9371            )
9372            .await
9373            .unwrap();
9374        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9375        assert_eq!(eligible.len(), 1);
9376        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9377        assert_eq!(
9378            eligible[0].get("branch").and_then(Value::as_str),
9379            Some("feature")
9380        );
9381    }
9382
9383    #[tokio::test]
9384    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9385    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9386        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
9387        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
9388        let (dir, head) = ready_worktree();
9389        let pr = format!(
9390            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9391        );
9392        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9393        let ghdir = tempfile::tempdir().unwrap();
9394        let guard = shim_lock();
9395        let bin = ghdir.path().join("fake-gh");
9396        write_exec_script(
9397            &bin,
9398            &format!(
9399                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
9400                enqueue =
9401                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9402            ),
9403        );
9404        let svc = WorktreesService::new();
9405        let reply = svc
9406            .merge_queue_with(
9407                MergeQueueRequest {
9408                    paths: vec![dir.path().to_path_buf()],
9409                    requester_key: Some("w1".into()),
9410                    check: false,
9411                    confirmed: true,
9412                },
9413                bin,
9414            )
9415            .await
9416            .unwrap();
9417        drop(guard);
9418        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9419        assert_eq!(queued.len(), 1, "{reply}");
9420        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9421        assert!(reply
9422            .get("failed")
9423            .and_then(Value::as_array)
9424            .unwrap()
9425            .is_empty());
9426    }
9427
9428    #[tokio::test]
9429    async fn concurrent_closes_overlap_their_heartbeat_waits() {
9430        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9431        let svc = Arc::new(WorktreesService::new());
9432        // Two *different* windows own the two targets — the multi-select shape.
9433        for (key, path) in [("w2", &first), ("w3", &second)] {
9434            svc.handle("register", json!({ "key": key, "folders": [path] }))
9435                .await
9436                .unwrap();
9437        }
9438
9439        let spawn_close = |path: PathBuf| {
9440            let svc = svc.clone();
9441            tokio::spawn(async move {
9442                svc.handle(
9443                    "close",
9444                    json!({
9445                        "path": path,
9446                        "remove": true,
9447                        "confirmed": true,
9448                        "requester_key": "w1",
9449                    }),
9450                )
9451                .await
9452            })
9453        };
9454        let a = spawn_close(first.clone());
9455        let b = spawn_close(second.clone());
9456
9457        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
9458        // *both* windows are told to close while *neither* op has finished, so the
9459        // two multi-second heartbeat waits are in flight at once. Take the guard
9460        // before `await_windows_closed` instead of after and this fails — op B
9461        // would sit on the lock without ever marking w3, restoring exactly the
9462        // N-stacked-waits latency the fan-out exists to remove.
9463        for key in ["w2", "w3"] {
9464            let mut saw_close = false;
9465            for _ in 0..400 {
9466                let hb = svc
9467                    .handle("heartbeat", json!({ "key": key }))
9468                    .await
9469                    .unwrap();
9470                if hb.get("close").and_then(Value::as_bool) == Some(true) {
9471                    saw_close = true;
9472                    break;
9473                }
9474                tokio::time::sleep(Duration::from_millis(5)).await;
9475            }
9476            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
9477        }
9478        assert!(
9479            !a.is_finished() && !b.is_finished(),
9480            "neither close can have finished: both windows are still registered"
9481        );
9482
9483        // Both windows close; both ops then remove.
9484        for key in ["w2", "w3"] {
9485            svc.handle("unregister", json!({ "key": key }))
9486                .await
9487                .unwrap();
9488        }
9489        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
9490        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
9491        assert!(!first.exists());
9492        assert!(!second.exists());
9493    }
9494
9495    #[tokio::test]
9496    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
9497        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
9498        let svc = WorktreesService::new();
9499        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
9500        // main, no risks → the extension proceeds with no dialog.
9501        let report = svc
9502            .handle("close", json!({ "path": wt_path, "remove": true }))
9503            .await
9504            .unwrap();
9505        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
9506        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
9507        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
9508        assert!(report
9509            .get("risks")
9510            .and_then(Value::as_array)
9511            .unwrap()
9512            .is_empty());
9513        // No side effects: the worktree still exists.
9514        assert!(wt_path.exists());
9515    }
9516
9517    #[tokio::test]
9518    async fn close_removes_a_clean_linked_worktree() {
9519        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
9520        let svc = WorktreesService::new();
9521        let reply = svc
9522            .handle(
9523                "close",
9524                json!({ "path": wt_path, "remove": true, "confirmed": true }),
9525            )
9526            .await
9527            .unwrap();
9528        assert_eq!(reply, json!({ "removed": true }));
9529        assert!(
9530            !wt_path.exists(),
9531            "the worktree directory should be deleted"
9532        );
9533    }
9534
9535    // --- Close-op audit logging (#1364) ------------------------------------
9536
9537    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
9538    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
9539    /// `with_default`, so it never disturbs a global subscriber other tests set.
9540    /// The audit-line tests drive the sync helpers directly (no runtime, no
9541    /// `spawn_blocking`), so the captured events fire on this thread where the
9542    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
9543    /// under the parallel suite is *not* reliably captured this way.
9544    #[derive(Clone, Default)]
9545    struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
9546
9547    impl std::io::Write for CaptureWriter {
9548        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
9549            self.0.lock().unwrap().extend_from_slice(buf);
9550            Ok(buf.len())
9551        }
9552        fn flush(&mut self) -> std::io::Result<()> {
9553            Ok(())
9554        }
9555    }
9556
9557    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
9558        type Writer = Self;
9559        fn make_writer(&'a self) -> Self::Writer {
9560            self.clone()
9561        }
9562    }
9563
9564    /// Runs `f` under a thread-local INFO-level subscriber and returns everything
9565    /// it logged. `f` must be fully synchronous on this thread.
9566    fn capture_info(f: impl FnOnce()) -> String {
9567        let writer = CaptureWriter::default();
9568        let subscriber = tracing_subscriber::fmt()
9569            .with_max_level(tracing::Level::INFO)
9570            .with_ansi(false)
9571            .with_writer(writer.clone())
9572            .finish();
9573        tracing::subscriber::with_default(subscriber, f);
9574        let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
9575        logs
9576    }
9577
9578    // ── rebase op (#1415) ──────────────────────────────────────────────────
9579
9580    /// A `RebaseRequest` over `paths` with everything else defaulted.
9581    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
9582        RebaseRequest {
9583            paths,
9584            requester_key: None,
9585            check: false,
9586            confirmed: false,
9587            keep_conflicts: false,
9588            autostash: false,
9589            onto: None,
9590        }
9591    }
9592
9593    /// A repo whose `main` has advanced one commit past a linked `feature`
9594    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
9595    ///
9596    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
9597    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
9598    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
9599    /// own concern and is covered in `worktree_rebase.rs`.
9600    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9601        let main_dir = tempfile::tempdir().unwrap();
9602        let repo = init_repo(main_dir.path());
9603        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
9604        repo.set_head("refs/heads/main").unwrap();
9605        let wt_parent = tempfile::tempdir().unwrap();
9606        let wt_path = wt_parent.path().join("feature-wt");
9607        add_worktree(&repo, base, &wt_path, "feature");
9608        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
9609        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
9610        (main_dir, wt_parent, wt_path)
9611    }
9612
9613    #[tokio::test]
9614    async fn rebase_with_refuses_an_empty_selection() {
9615        // A bare `rebase` must be a usage error, never a silent mass-rebase.
9616        let svc = WorktreesService::new();
9617        let err = svc
9618            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
9619            .await
9620            .unwrap_err()
9621            .to_string();
9622        assert!(err.contains("at least one path"), "{err}");
9623    }
9624
9625    #[tokio::test]
9626    async fn rebase_with_phase_one_reports_without_rebasing() {
9627        let (_main, _parent, wt) = behind_worktree();
9628        let before = Repository::open(&wt).unwrap().head().unwrap().target();
9629
9630        let svc = WorktreesService::new();
9631        let reply = svc
9632            .rebase_with(
9633                RebaseRequest {
9634                    check: true,
9635                    onto: Some("main".into()),
9636                    ..rebase_req(vec![wt.clone()])
9637                },
9638                crate::git::resolve_git_binary(),
9639            )
9640            .await
9641            .unwrap();
9642
9643        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9644        assert_eq!(worktrees.len(), 1, "{reply}");
9645        assert_eq!(
9646            worktrees[0].get("status").and_then(Value::as_str),
9647            Some("would-rebase"),
9648            "{reply}"
9649        );
9650        // A local onto ref means no fetch was attempted at all.
9651        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
9652        assert_eq!(fetches.len(), 1);
9653        assert_eq!(
9654            fetches[0].get("fetched").and_then(Value::as_bool),
9655            Some(false)
9656        );
9657        assert_eq!(
9658            Repository::open(&wt).unwrap().head().unwrap().target(),
9659            before,
9660            "phase 1 must not move the branch"
9661        );
9662    }
9663
9664    #[tokio::test]
9665    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
9666        let (_main, _parent, wt) = behind_worktree();
9667        let svc = WorktreesService::new();
9668        let reply = svc
9669            .rebase_with(
9670                RebaseRequest {
9671                    confirmed: true,
9672                    onto: Some("main".into()),
9673                    ..rebase_req(vec![wt.clone()])
9674                },
9675                crate::git::resolve_git_binary(),
9676            )
9677            .await
9678            .unwrap();
9679
9680        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9681        assert_eq!(
9682            worktrees[0].get("status").and_then(Value::as_str),
9683            Some("rebased"),
9684            "{reply}"
9685        );
9686        // The transient cue is cleared on the way out, so no row keeps spinning.
9687        assert!(
9688            svc.registry.rebasing_paths().is_empty(),
9689            "the rebasing mark must be cleared after the execute"
9690        );
9691    }
9692
9693    #[tokio::test]
9694    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
9695        // The re-validation that makes two-phase meaningful: a `confirmed` request
9696        // still runs the classifier, so a worktree that is dirty *now* is skipped
9697        // rather than rebased on the strength of an earlier phase-1 verdict.
9698        let (_main, _parent, wt) = behind_worktree();
9699        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
9700
9701        let svc = WorktreesService::new();
9702        let reply = svc
9703            .rebase_with(
9704                RebaseRequest {
9705                    confirmed: true,
9706                    onto: Some("main".into()),
9707                    ..rebase_req(vec![wt.clone()])
9708                },
9709                crate::git::resolve_git_binary(),
9710            )
9711            .await
9712            .unwrap();
9713
9714        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9715        assert_eq!(
9716            worktrees[0].get("status").and_then(Value::as_str),
9717            Some("skipped"),
9718            "{reply}"
9719        );
9720        assert_eq!(
9721            worktrees[0].get("reason").and_then(Value::as_str),
9722            Some("dirty"),
9723            "{reply}"
9724        );
9725    }
9726
9727    #[tokio::test]
9728    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
9729        // The hazard the plan-under-the-lock ordering exists to prevent: if another
9730        // run has left a worktree mid-rebase, this one must classify it as
9731        // `operation-in-progress` and leave it alone — never run `git rebase`
9732        // against it, which (without `keep_conflicts`) would `--abort` and destroy
9733        // the conflict resolution in progress.
9734        let (_main, _parent, wt) = behind_worktree();
9735        // Fake a rebase in progress the way git does: the state directory's
9736        // presence is what `Repository::state()` keys on.
9737        std::fs::create_dir_all(wt.join(".git")).ok();
9738        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
9739        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
9740        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
9741        assert_ne!(
9742            Repository::open(&wt).unwrap().state(),
9743            RepositoryState::Clean,
9744            "precondition: the worktree looks mid-rebase to git2"
9745        );
9746
9747        let svc = WorktreesService::new();
9748        let reply = svc
9749            .rebase_with(
9750                RebaseRequest {
9751                    confirmed: true,
9752                    onto: Some("main".into()),
9753                    ..rebase_req(vec![wt.clone()])
9754                },
9755                crate::git::resolve_git_binary(),
9756            )
9757            .await
9758            .unwrap();
9759
9760        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
9761        assert_eq!(
9762            worktrees[0].get("reason").and_then(Value::as_str),
9763            Some("operation-in-progress"),
9764            "{reply}"
9765        );
9766        // Still mid-rebase: nothing aborted it out from under whoever owns it.
9767        assert_ne!(
9768            Repository::open(&wt).unwrap().state(),
9769            RepositoryState::Clean
9770        );
9771    }
9772
9773    #[test]
9774    fn rebase_request_maps_onto_engine_options() {
9775        let req = RebaseRequest {
9776            keep_conflicts: true,
9777            autostash: true,
9778            onto: Some("origin/release".into()),
9779            ..rebase_req(vec![PathBuf::from("/wt")])
9780        };
9781        let opts = req.options(PathBuf::from("/custom/git"));
9782        assert!(opts.keep_conflicts && opts.autostash);
9783        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
9784        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
9785        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
9786        // engine's own flag stays off — setting it would be a second, redundant
9787        // gate that could silently no-op a confirmed execute.
9788        assert!(!opts.dry_run);
9789    }
9790
9791    #[test]
9792    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
9793        let req = RebaseRequest {
9794            requester_key: Some("win-3".into()),
9795            check: true,
9796            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
9797        };
9798        let plan = worktree_rebase::Plan {
9799            fetches: vec![worktree_rebase::FetchOutcome {
9800                repo_root: PathBuf::from("/repo"),
9801                onto: "origin/main".into(),
9802                fetched: true,
9803                ok: false,
9804                detail: Some("host unreachable".into()),
9805            }],
9806            worktrees: vec![
9807                worktree_rebase::WorktreeOutcome {
9808                    path: PathBuf::from("/a"),
9809                    branch: Some("a".into()),
9810                    onto: "origin/main".into(),
9811                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
9812                },
9813                worktree_rebase::WorktreeOutcome {
9814                    path: PathBuf::from("/b"),
9815                    branch: Some("b".into()),
9816                    onto: "origin/main".into(),
9817                    result: worktree_rebase::RebaseResult::UpToDate,
9818                },
9819            ],
9820        };
9821        let logs = capture_info(|| log_rebase_check(&req, &plan));
9822        assert!(logs.contains("rebase check"), "{logs}");
9823        assert!(logs.contains("win-3"), "{logs}");
9824        assert!(logs.contains("requested=2"), "{logs}");
9825        assert!(logs.contains("pending=1"), "{logs}");
9826        assert!(logs.contains("failed_fetches=1"), "{logs}");
9827    }
9828
9829    #[test]
9830    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
9831        // A CLI-style requester (no window key) logs the dash fallback.
9832        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
9833        let outcome = |result| worktree_rebase::WorktreeOutcome {
9834            path: PathBuf::from("/x"),
9835            branch: Some("x".into()),
9836            onto: "origin/main".into(),
9837            result,
9838        };
9839        let outcomes = vec![
9840            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
9841            outcome(worktree_rebase::RebaseResult::Conflict {
9842                detail: "CONFLICT".into(),
9843                left_in_place: true,
9844            }),
9845            outcome(worktree_rebase::RebaseResult::Skipped {
9846                reason: worktree_rebase::SkipReason::Dirty,
9847            }),
9848        ];
9849        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
9850        assert!(logs.contains("rebase execute"), "{logs}");
9851        assert!(logs.contains("rebased=1"), "{logs}");
9852        assert!(logs.contains("conflicts=1"), "{logs}");
9853        assert!(logs.contains("left_in_place=1"), "{logs}");
9854        assert!(logs.contains("skipped=1"), "{logs}");
9855        assert!(logs.contains(r#"requester="-""#), "{logs}");
9856    }
9857
9858    #[test]
9859    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
9860        assert_eq!(operation_slug(RepositoryState::Clean), None);
9861        assert_eq!(
9862            operation_slug(RepositoryState::Rebase).as_deref(),
9863            Some("rebase")
9864        );
9865        assert_eq!(
9866            operation_slug(RepositoryState::RebaseMerge).as_deref(),
9867            Some("rebase"),
9868            "the merge-backend rebase is still just a rebase to the user"
9869        );
9870        assert_eq!(
9871            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
9872            Some("rebase-interactive")
9873        );
9874        assert_eq!(
9875            operation_slug(RepositoryState::Merge).as_deref(),
9876            Some("merge")
9877        );
9878        assert_eq!(
9879            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
9880            Some("cherry-pick")
9881        );
9882        assert_eq!(
9883            operation_slug(RepositoryState::RevertSequence).as_deref(),
9884            Some("revert")
9885        );
9886        assert_eq!(
9887            operation_slug(RepositoryState::Bisect).as_deref(),
9888            Some("bisect")
9889        );
9890        assert_eq!(
9891            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
9892            Some("apply-mailbox")
9893        );
9894    }
9895
9896    #[test]
9897    fn git_status_omits_operation_for_a_clean_worktree() {
9898        let dir = tempfile::tempdir().unwrap();
9899        let _repo = diverging_repo(dir.path());
9900        assert_eq!(
9901            git_status(dir.path()).operation,
9902            None,
9903            "a clean worktree carries no operation, so the field stays off the wire"
9904        );
9905    }
9906
9907    #[test]
9908    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
9909        let main_dir = tempfile::tempdir().unwrap();
9910        let repo = init_repo(main_dir.path());
9911        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9912        repo.set_head("refs/heads/main").unwrap();
9913        let path = canonical(main_dir.path());
9914
9915        let quiet = worktree_entry(&path, true, &HashMap::new(), &HashSet::new());
9916        assert!(!quiet.rebasing);
9917        // Byte-identical for a pre-#1415 client: neither new field is serialized.
9918        let json = serde_json::to_value(&quiet).unwrap();
9919        assert!(json.get("rebasing").is_none(), "{json}");
9920        assert!(json.get("operation").is_none(), "{json}");
9921
9922        let busy = worktree_entry(
9923            &path,
9924            true,
9925            &HashMap::new(),
9926            &std::iter::once(path.clone()).collect(),
9927        );
9928        assert!(busy.rebasing, "the registry's transient mark rides through");
9929        assert_eq!(
9930            serde_json::to_value(&busy).unwrap()["rebasing"],
9931            serde_json::Value::Bool(true)
9932        );
9933    }
9934
9935    #[test]
9936    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
9937        assert_eq!(note_kinds(&[]), "-");
9938        assert_eq!(
9939            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
9940            "dirty,untracked"
9941        );
9942    }
9943
9944    #[test]
9945    fn is_self_close_true_only_when_requester_owns_an_open_window() {
9946        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
9947        assert!(is_self_close(Some("w1"), &windows));
9948        assert!(
9949            !is_self_close(Some("w3"), &windows),
9950            "requester owns no window"
9951        );
9952        assert!(!is_self_close(None, &windows), "no requester");
9953        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
9954    }
9955
9956    #[test]
9957    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
9958        let logs = capture_info(|| {
9959            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
9960            assert_eq!(reply, json!({ "removed": true }));
9961        });
9962        assert!(
9963            logs.contains("worktrees close: linked worktree pruned"),
9964            "a successful prune must log an INFO audit line, got: {logs}"
9965        );
9966        assert!(
9967            logs.contains("/wt/feature"),
9968            "the target path must ride the line, got: {logs}"
9969        );
9970    }
9971
9972    #[test]
9973    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
9974        // The #1403 fix: an already-removed worktree still replies `removed: true`
9975        // (the row should go) but must NOT log the `pruned` line — it logs the
9976        // distinct `already-gone` outcome so the audit trail stops conflating the
9977        // two.
9978        let logs = capture_info(|| {
9979            let reply =
9980                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
9981            assert_eq!(reply, json!({ "removed": true }));
9982        });
9983        assert!(
9984            logs.contains("worktrees close: nothing to prune, worktree already removed"),
9985            "an already-gone close must log its own outcome, got: {logs}"
9986        );
9987        assert!(
9988            !logs.contains("linked worktree pruned"),
9989            "an already-gone close must not claim it pruned, got: {logs}"
9990        );
9991    }
9992
9993    #[test]
9994    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
9995        // ERROR is more severe than the INFO cap, so `capture_info` records it.
9996        let logs = capture_info(|| {
9997            let err = log_close_error(
9998                Path::new("/wt/feature"),
9999                "safety check",
10000                anyhow!("not a git worktree"),
10001            );
10002            assert_eq!(
10003                err.to_string(),
10004                "not a git worktree",
10005                "err propagates unchanged"
10006            );
10007        });
10008        assert!(
10009            logs.contains("worktrees close: safety check failed"),
10010            "a failed phase must log an ERROR audit line, got: {logs}"
10011        );
10012        assert!(
10013            logs.contains("not a git worktree"),
10014            "the cause must ride the line, got: {logs}"
10015        );
10016        assert!(
10017            logs.contains("/wt/feature"),
10018            "the target path must ride the line, got: {logs}"
10019        );
10020    }
10021
10022    #[test]
10023    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10024        let logs = capture_info(|| {
10025            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10026            assert!(err.is_err(), "a prune failure must propagate");
10027        });
10028        assert!(
10029            logs.contains("worktrees close: worktree prune failed"),
10030            "a prune failure must log a WARN audit line, got: {logs}"
10031        );
10032        assert!(
10033            logs.contains("locked"),
10034            "the failure cause must ride the line, got: {logs}"
10035        );
10036    }
10037
10038    #[test]
10039    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10040        let git = GitSafety {
10041            is_main: false,
10042            removable: true,
10043            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10044            info: vec![],
10045        };
10046        let logs = capture_info(|| {
10047            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10048        });
10049        assert!(
10050            logs.contains("worktrees close: safety check"),
10051            "phase-1 must log a safety-check line, got: {logs}"
10052        );
10053        assert!(
10054            logs.contains("/wt/feature"),
10055            "the path must ride the line, got: {logs}"
10056        );
10057        assert!(
10058            logs.contains("window_key=\"win-42\""),
10059            "the owning window key must ride the line, got: {logs}"
10060        );
10061        assert!(logs.contains("removable=true"), "got: {logs}");
10062        assert!(logs.contains("is_main=false"), "got: {logs}");
10063        assert!(logs.contains("open=true"), "got: {logs}");
10064        assert!(
10065            logs.contains("risks=dirty,untracked"),
10066            "the blocking risk kinds must ride the line, got: {logs}"
10067        );
10068    }
10069
10070    #[test]
10071    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10072        let git = GitSafety {
10073            is_main: false,
10074            removable: true,
10075            risks: vec![],
10076            info: vec![],
10077        };
10078        let logs = capture_info(|| {
10079            log_safety_check(Path::new("/wt/feature"), None, &git, false);
10080        });
10081        assert!(
10082            logs.contains("window_key=\"-\""),
10083            "no owning window → dash, got: {logs}"
10084        );
10085        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
10086    }
10087
10088    #[test]
10089    fn log_executing_logs_the_routing_decision() {
10090        let logs = capture_info(|| {
10091            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
10092        });
10093        assert!(
10094            logs.contains("worktrees close: executing"),
10095            "phase-2 must log the execute routing, got: {logs}"
10096        );
10097        assert!(
10098            logs.contains("requester=\"win-7\""),
10099            "the requester key must ride the line, got: {logs}"
10100        );
10101        assert!(logs.contains("remove=true"), "got: {logs}");
10102        assert!(logs.contains("self_close=false"), "got: {logs}");
10103        assert!(logs.contains("cross_window=3"), "got: {logs}");
10104    }
10105
10106    #[test]
10107    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
10108        let logs = capture_info(|| {
10109            log_close_abort(
10110                Path::new("/wt/feature"),
10111                &anyhow!("window(s) did not close in time: win-9"),
10112            );
10113        });
10114        assert!(
10115            logs.contains("worktrees close: aborted"),
10116            "an abort must log a WARN audit line, got: {logs}"
10117        );
10118        assert!(
10119            logs.contains("/wt/feature"),
10120            "the path must ride the line, got: {logs}"
10121        );
10122        assert!(
10123            logs.contains("win-9"),
10124            "the still-open window must ride the line, got: {logs}"
10125        );
10126    }
10127
10128    #[test]
10129    fn log_window_closed_logs_the_no_removal_outcome() {
10130        let logs = capture_info(|| {
10131            log_window_closed(Path::new("/wt/feature"));
10132        });
10133        assert!(
10134            logs.contains("worktrees close: window closed, no removal"),
10135            "a remove:false close must log the no-removal outcome, got: {logs}"
10136        );
10137        assert!(
10138            logs.contains("/wt/feature"),
10139            "the path must ride the line, got: {logs}"
10140        );
10141    }
10142
10143    #[test]
10144    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
10145        // The reorder (#1315) must still fully remove a worktree: both the
10146        // checked-out directory *and* the admin metadata git tracks it by, so it
10147        // no longer appears in `Repository::worktrees()`.
10148        let (main, _wtp, wt_path) = repo_with_linked_worktree();
10149        let admin = main.path().join(".git").join("worktrees").join("feature");
10150        assert!(admin.exists(), "admin metadata should exist before removal");
10151
10152        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
10153
10154        assert!(!wt_path.exists(), "the working directory should be gone");
10155        assert!(!admin.exists(), "the admin metadata should be pruned");
10156        let main_repo = Repository::open(main.path()).unwrap();
10157        assert_eq!(
10158            main_repo.worktrees().unwrap().len(),
10159            0,
10160            "git should no longer track the worktree"
10161        );
10162    }
10163
10164    #[test]
10165    fn remove_worktree_recovers_a_half_removed_orphan() {
10166        // The exact #1315 leftover: the old ordering deleted the admin metadata
10167        // first, then failed to rmdir the working tree, orphaning the directory
10168        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
10169        // rather than error with "not a git worktree".
10170        let (main, _wtp, wt_path) = repo_with_linked_worktree();
10171        let admin = main.path().join(".git").join("worktrees").join("feature");
10172        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
10173        std::fs::remove_dir_all(&admin).unwrap();
10174        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
10175        assert!(
10176            Repository::open(&wt_path).is_err(),
10177            "the orphan should not open as a repo"
10178        );
10179
10180        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
10181        assert!(
10182            !wt_path.exists(),
10183            "the leftover directory should be removed"
10184        );
10185    }
10186
10187    /// A linked worktree whose working directory has been deleted out-of-band,
10188    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
10189    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
10190    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
10191    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
10192    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
10193    /// admin dir)`.
10194    fn orphaned_admin_worktree() -> (
10195        tempfile::TempDir,
10196        PathBuf,
10197        tempfile::TempDir,
10198        PathBuf,
10199        PathBuf,
10200    ) {
10201        let main_dir = tempfile::tempdir().unwrap();
10202        let main_root = main_dir.path().canonicalize().unwrap();
10203        let repo = init_repo(&main_root);
10204        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10205        repo.set_head("refs/heads/trunk").unwrap();
10206        let wt_parent = tempfile::tempdir().unwrap();
10207        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
10208        add_worktree(&repo, a, &wt_path, "feature");
10209        let admin = main_root.join(".git").join("worktrees").join("feature");
10210        assert!(admin.exists(), "admin metadata exists before the orphaning");
10211        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
10212        std::fs::remove_dir_all(&wt_path).unwrap();
10213        (main_dir, main_root, wt_parent, wt_path, admin)
10214    }
10215
10216    fn window_on(folder: &Path) -> WindowEntry {
10217        WindowEntry {
10218            key: "w".to_string(),
10219            folders: vec![folder.to_path_buf()],
10220            repo: None,
10221            title: None,
10222            pid: None,
10223            last_seen: Utc::now(),
10224        }
10225    }
10226
10227    #[test]
10228    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
10229        // #1403: working tree gone, admin present. An external worktree shares no
10230        // ancestor with its repo, so the owner is found via a live window on the
10231        // main repo — the same window whose repo enumeration showed the stuck row.
10232        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10233
10234        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
10235
10236        assert_eq!(
10237            removed,
10238            Removal::Pruned,
10239            "the orphaned admin must be pruned"
10240        );
10241        assert!(!admin.exists(), "the admin metadata should be gone");
10242        let main_repo = Repository::open(&main_root).unwrap();
10243        assert!(
10244            main_repo.worktrees().unwrap().is_empty(),
10245            "git should no longer track the orphaned worktree"
10246        );
10247    }
10248
10249    #[test]
10250    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
10251        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
10252        // worktrees/<name>` shape that produced the reported orphans) is located
10253        // by walking the gone path's ancestors — no registered window needed.
10254        let main_dir = tempfile::tempdir().unwrap();
10255        let main_root = main_dir.path().canonicalize().unwrap();
10256        let repo = init_repo(&main_root);
10257        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10258        repo.set_head("refs/heads/trunk").unwrap();
10259        // git2's `worktree()` creates the leaf but not intermediate parents.
10260        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
10261        let wt_path = main_root.join(".nested").join("feature-wt");
10262        add_worktree(&repo, a, &wt_path, "feature");
10263        let admin = main_root.join(".git").join("worktrees").join("feature");
10264        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
10265
10266        let removed = remove_worktree(&wt_path, &[]).unwrap();
10267
10268        assert_eq!(removed, Removal::Pruned);
10269        assert!(!admin.exists(), "the admin metadata should be gone");
10270    }
10271
10272    #[test]
10273    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
10274        // The other side of #1403: working tree gone AND admin already pruned. No
10275        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
10276        // never a `pruned` lie.
10277        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10278        // Prune the admin entry too, so nothing remains to remove.
10279        std::fs::remove_dir_all(&admin).unwrap();
10280
10281        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
10282
10283        assert_eq!(removed, Removal::AlreadyGone);
10284    }
10285
10286    #[test]
10287    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
10288        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
10289        // External worktree: no ancestor is the repo, so only the window feed finds
10290        // it. The main root rides through, deduped to a single entry.
10291        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
10292        assert!(
10293            roots.contains(&main_root),
10294            "the owning main repo must be a candidate, got: {roots:?}"
10295        );
10296    }
10297
10298    #[test]
10299    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
10300        // A candidate root that does not open as a repo (a stale registry folder,
10301        // a deleted repo) is skipped rather than fatal; the real owner that
10302        // follows still prunes.
10303        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10304        let junk = tempfile::tempdir().unwrap();
10305
10306        let removed =
10307            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
10308
10309        assert_eq!(removed, Removal::Pruned);
10310        assert!(
10311            !admin.exists(),
10312            "the real owner must still prune the orphan"
10313        );
10314    }
10315
10316    #[test]
10317    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
10318        // A candidate that opens as a repo but is a *linked* worktree carries no
10319        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
10320        // is the real owner. (A live sibling worktree is exactly such a candidate.)
10321        let main_dir = tempfile::tempdir().unwrap();
10322        let main_root = main_dir.path().canonicalize().unwrap();
10323        let repo = init_repo(&main_root);
10324        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10325        repo.set_head("refs/heads/trunk").unwrap();
10326        let wt_parent = tempfile::tempdir().unwrap();
10327        let wt_root = wt_parent.path().canonicalize().unwrap();
10328        let orphan = wt_root.join("orphan-wt");
10329        let sibling = wt_root.join("sibling-wt");
10330        add_worktree(&repo, a, &orphan, "orphan");
10331        add_worktree(&repo, a, &sibling, "sibling");
10332        let admin = main_root.join(".git").join("worktrees").join("orphan");
10333        std::fs::remove_dir_all(&orphan).unwrap();
10334
10335        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
10336        // main repo second (the actual owner).
10337        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
10338
10339        assert_eq!(removed, Removal::Pruned);
10340        assert!(
10341            !admin.exists(),
10342            "the orphan's admin metadata must be pruned"
10343        );
10344    }
10345
10346    #[test]
10347    fn prune_orphaned_admin_refuses_a_locked_orphan() {
10348        // Locking is an admin-dir file, independent of the (gone) checkout, so an
10349        // orphaned worktree can still be locked. The prune must refuse it —
10350        // "unlock first" — rather than force past, mirroring the live path.
10351        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10352        let main_repo = Repository::open(&main_root).unwrap();
10353        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
10354        main_repo
10355            .find_worktree(&name)
10356            .unwrap()
10357            .lock(Some("in use"))
10358            .unwrap();
10359
10360        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
10361
10362        assert!(
10363            err.to_string().contains("locked"),
10364            "a locked orphan must be refused, got: {err:#}"
10365        );
10366        assert!(admin.exists(), "a refused prune must leave the admin entry");
10367    }
10368
10369    #[test]
10370    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
10371        let (main, _wtp, wt_path) = repo_with_linked_worktree();
10372        // A live worktree: gitlink resolves → not an orphan.
10373        assert!(!is_orphaned_worktree(&wt_path));
10374        // The main checkout has a `.git` directory → not an orphan.
10375        assert!(!is_orphaned_worktree(main.path()));
10376        // Drop the admin metadata → the gitlink now dangles → orphan.
10377        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
10378            .unwrap();
10379        assert!(is_orphaned_worktree(&wt_path));
10380    }
10381
10382    #[test]
10383    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
10384        let tmp = tempfile::tempdir().unwrap();
10385        let missing = tmp.path().join("gone");
10386        assert!(remove_dir_all_retrying(&missing).is_ok());
10387    }
10388
10389    #[test]
10390    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
10391        use std::io::Error;
10392        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
10393            assert!(
10394                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
10395                "errno {errno} is the concurrent-writer race and must be retried"
10396            );
10397        }
10398        // A hard failure must surface immediately rather than burn the backoff
10399        // waiting for a condition that will never clear.
10400        for errno in [
10401            nix::libc::EACCES,
10402            nix::libc::EPERM,
10403            nix::libc::EROFS,
10404            nix::libc::ENOTDIR,
10405        ] {
10406            assert!(
10407                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
10408                "errno {errno} is permanent and must not be retried"
10409            );
10410        }
10411        // Not from the OS at all, so there is no errno to classify.
10412        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
10413    }
10414
10415    #[test]
10416    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
10417        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
10418        // race, so it must fail on the first attempt with the original cause
10419        // attached, leaving the path untouched.
10420        let tmp = tempfile::tempdir().unwrap();
10421        let file = tmp.path().join("not-a-directory");
10422        std::fs::write(&file, b"x").unwrap();
10423
10424        let mut attempts = 0;
10425        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
10426            attempts += 1;
10427            std::fs::remove_dir_all(&file)
10428        })
10429        .unwrap_err();
10430
10431        assert_eq!(attempts, 1, "a permanent error must not be retried");
10432        assert!(
10433            err.to_string()
10434                .contains("failed to remove worktree directory"),
10435            "unexpected error: {err:#}"
10436        );
10437        assert!(err.source().is_some(), "the io::Error cause is preserved");
10438        assert!(file.exists());
10439    }
10440
10441    #[test]
10442    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
10443        // A writer that never quiesces: every sweep re-finds the directory
10444        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
10445        // than the loop spinning forever.
10446        let tmp = tempfile::tempdir().unwrap();
10447        let mut attempts = 0;
10448        let backoff = [Duration::ZERO, Duration::ZERO];
10449        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
10450            attempts += 1;
10451            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
10452        })
10453        .unwrap_err();
10454
10455        // One attempt per delay, plus the initial one.
10456        assert_eq!(attempts, backoff.len() + 1);
10457        assert!(
10458            err.to_string()
10459                .contains("failed to remove worktree directory"),
10460            "unexpected error: {err:#}"
10461        );
10462    }
10463
10464    #[test]
10465    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
10466        // The #1315 happy path, deterministically: the race clears partway through
10467        // the schedule and the removal then succeeds.
10468        let tmp = tempfile::tempdir().unwrap();
10469        let mut attempts = 0;
10470        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
10471            attempts += 1;
10472            if attempts < 3 {
10473                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
10474            } else {
10475                Ok(())
10476            }
10477        });
10478        assert!(result.is_ok(), "{result:?}");
10479        assert_eq!(attempts, 3);
10480    }
10481
10482    #[test]
10483    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
10484        // A `.git` file that is readable but carries no `gitdir:` pointer is not
10485        // something we may delete.
10486        let tmp = tempfile::tempdir().unwrap();
10487        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
10488        assert!(!is_orphaned_worktree(tmp.path()));
10489    }
10490
10491    #[test]
10492    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
10493        // Neither a repo nor an orphan: refuse it rather than recursively deleting
10494        // whatever directory was passed in.
10495        let tmp = tempfile::tempdir().unwrap();
10496        let plain = tmp.path().join("plain");
10497        std::fs::create_dir(&plain).unwrap();
10498
10499        let err = remove_worktree(&plain, &[]).unwrap_err();
10500
10501        assert!(
10502            err.to_string().contains("not a git worktree"),
10503            "unexpected error: {err:#}"
10504        );
10505        assert!(plain.exists(), "a non-worktree path must be left alone");
10506    }
10507
10508    #[test]
10509    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
10510        // Acceptance criterion (#1315): a language server / cargo still writing
10511        // into `target/` as the window closes makes the recursive rmdir race with
10512        // "Directory not empty". A background thread reproduces that by
10513        // repopulating `target/` for a bounded window; removal must retry past it
10514        // and still succeed once the writer stops.
10515        use std::sync::atomic::{AtomicBool, Ordering};
10516        use std::sync::Arc;
10517
10518        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10519        // Created once, here — never inside the writer loop. `create_dir_all`
10520        // rebuilds every *parent* component, so calling it per iteration let the
10521        // writer resurrect the worktree root the instant removal won the race,
10522        // failing the final assertion on a directory removal had correctly
10523        // deleted and the test itself put back (#1410).
10524        let nested = wt_path.join("target").join("nested");
10525        std::fs::create_dir_all(&nested).unwrap();
10526
10527        let stop = Arc::new(AtomicBool::new(false));
10528        let writer_stop = Arc::clone(&stop);
10529        let writer_dir = nested;
10530        let writer = std::thread::spawn(move || {
10531            let mut n = 0u64;
10532            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
10533            // stop so a later removal pass finds the directory quiescent.
10534            let deadline = std::time::Instant::now() + Duration::from_millis(400);
10535            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
10536                // Best-effort, and deliberately creating no directory: `fs::write`
10537                // is `File::create`, which never makes parents. While `target/`
10538                // survives these keep it non-empty — the ENOTEMPTY removal has to
10539                // retry past — and once removal wins they simply fail with ENOENT.
10540                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
10541                n += 1;
10542            }
10543        });
10544
10545        let result = remove_worktree(&wt_path, &[]);
10546        stop.store(true, Ordering::Relaxed);
10547        writer.join().unwrap();
10548
10549        assert!(
10550            result.is_ok(),
10551            "removal should retry past the writer: {result:?}"
10552        );
10553        assert!(!wt_path.exists(), "the worktree directory should be gone");
10554    }
10555
10556    #[tokio::test]
10557    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
10558        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10559        // An untracked file in the worktree would be lost on removal.
10560        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
10561
10562        let svc = WorktreesService::new();
10563        let report = svc
10564            .handle("close", json!({ "path": wt_path, "remove": true }))
10565            .await
10566            .unwrap();
10567        let risks = report.get("risks").and_then(Value::as_array).unwrap();
10568        assert!(
10569            risks
10570                .iter()
10571                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
10572            "expected an untracked risk: {report}"
10573        );
10574        // Still removable — the risk only means "confirm first", not "refuse".
10575        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10576        // The unconfirmed check has no side effects.
10577        assert!(wt_path.exists());
10578    }
10579
10580    #[tokio::test]
10581    async fn close_confirmed_removes_a_dirty_worktree() {
10582        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10583        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
10584        let svc = WorktreesService::new();
10585        // With confirmation, the risks are overridden and removal proceeds.
10586        let reply = svc
10587            .handle(
10588                "close",
10589                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10590            )
10591            .await
10592            .unwrap();
10593        assert_eq!(reply, json!({ "removed": true }));
10594        assert!(!wt_path.exists());
10595    }
10596
10597    #[tokio::test]
10598    async fn close_refuses_to_remove_the_main_working_tree() {
10599        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
10600        let svc = WorktreesService::new();
10601        // Phase 1: the main tree reports not-removable, marked main.
10602        let report = svc
10603            .handle("close", json!({ "path": main.path(), "remove": true }))
10604            .await
10605            .unwrap();
10606        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
10607        assert_eq!(
10608            report.get("removable").and_then(Value::as_bool),
10609            Some(false)
10610        );
10611        // Phase 2: even a confirmed delete of the main tree is refused
10612        // defensively, and the directory is untouched.
10613        assert!(svc
10614            .handle(
10615                "close",
10616                json!({ "path": main.path(), "remove": true, "confirmed": true }),
10617            )
10618            .await
10619            .is_err());
10620        assert!(main.path().exists());
10621    }
10622
10623    #[tokio::test]
10624    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
10625        // The case a naive impl would wrongly protect: a linked worktree checked
10626        // out on `main` (the default branch) is *still a linked worktree*, so it
10627        // is fully deletable — and `main` survives (removal never deletes a branch).
10628        let main_dir = tempfile::tempdir().unwrap();
10629        let repo = init_repo(main_dir.path());
10630        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10631        repo.set_head("refs/heads/trunk").unwrap();
10632        let wt_parent = tempfile::tempdir().unwrap();
10633        let wt_path = wt_parent.path().join("main-wt");
10634        add_worktree(&repo, a, &wt_path, "main");
10635
10636        let svc = WorktreesService::new();
10637        let reply = svc
10638            .handle(
10639                "close",
10640                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10641            )
10642            .await
10643            .unwrap();
10644        assert_eq!(reply, json!({ "removed": true }));
10645        assert!(!wt_path.exists());
10646        // The `main` branch is untouched by the worktree removal.
10647        assert!(
10648            repo.find_branch("main", git2::BranchType::Local).is_ok(),
10649            "the default branch must survive worktree removal"
10650        );
10651    }
10652
10653    #[tokio::test]
10654    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
10655        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10656        let svc = WorktreesService::new();
10657        // First removal succeeds.
10658        svc.handle(
10659            "close",
10660            json!({ "path": wt_path, "remove": true, "confirmed": true }),
10661        )
10662        .await
10663        .unwrap();
10664        // A second confirmed close of the now-missing path is a clean success,
10665        // not an error (a stale snapshot must not crash).
10666        let reply = svc
10667            .handle(
10668                "close",
10669                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10670            )
10671            .await
10672            .unwrap();
10673        assert_eq!(reply, json!({ "removed": true }));
10674    }
10675
10676    #[tokio::test]
10677    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
10678        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
10679        // behind so the tree view keeps showing a `prunable` row. Closing it must
10680        // actually prune the admin metadata (via the registered window's repo), not
10681        // report a `pruned` no-op that leaves the row stuck.
10682        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
10683        let svc = WorktreesService::new();
10684        // A live window on the main repo — the vantage point the stuck row is
10685        // enumerated from, and the one the prune locates the owner through.
10686        svc.handle(
10687            "register",
10688            register_payload("main-w", None, &main_root.display().to_string()),
10689        )
10690        .await
10691        .unwrap();
10692
10693        // Before: the orphaned linked worktree still shows alongside the main tree.
10694        let before = svc.handle("tree", Value::Null).await.unwrap();
10695        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
10696        assert_eq!(
10697            worktrees_before, 2,
10698            "the orphaned row is present before close"
10699        );
10700
10701        let reply = svc
10702            .handle(
10703                "close",
10704                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10705            )
10706            .await
10707            .unwrap();
10708        assert_eq!(reply, json!({ "removed": true }));
10709
10710        // After: admin metadata pruned and the row is gone from the tree view.
10711        assert!(!admin.exists(), "the admin metadata must be pruned");
10712        let after = svc.handle("tree", Value::Null).await.unwrap();
10713        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
10714        assert_eq!(worktrees_after, 1, "only the main working tree remains");
10715    }
10716
10717    #[tokio::test]
10718    async fn close_safety_check_detects_detached_head_unreachable_commits() {
10719        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10720        // In the worktree, commit onto a detached HEAD so the new commit is
10721        // reachable from no ref — it would be GC'd on removal.
10722        let wt_repo = Repository::open(&wt_path).unwrap();
10723        let parent_oid = wt_repo.head().unwrap().target().unwrap();
10724        let parent = wt_repo.find_commit(parent_oid).unwrap();
10725        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
10726        wt_repo.set_head_detached(orphan).unwrap();
10727
10728        let svc = WorktreesService::new();
10729        let report = svc
10730            .handle("close", json!({ "path": wt_path, "remove": true }))
10731            .await
10732            .unwrap();
10733        let risks = report.get("risks").and_then(Value::as_array).unwrap();
10734        assert!(
10735            risks
10736                .iter()
10737                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
10738            "expected an unreachable-commits risk: {report}"
10739        );
10740    }
10741
10742    #[tokio::test]
10743    async fn close_self_close_removes_when_the_requester_owns_the_target() {
10744        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10745        let svc = WorktreesService::new();
10746        // The requesting window itself has the worktree open: it is the only
10747        // owning window, so there is nothing to wait on — remove and reply, and
10748        // the extension closes its own window on `ok`.
10749        svc.handle(
10750            "register",
10751            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
10752        )
10753        .await
10754        .unwrap();
10755        let reply = svc
10756            .handle(
10757                "close",
10758                json!({
10759                    "path": wt_path,
10760                    "remove": true,
10761                    "confirmed": true,
10762                    "requester_key": "w1",
10763                }),
10764            )
10765            .await
10766            .unwrap();
10767        assert_eq!(reply, json!({ "removed": true }));
10768        assert!(!wt_path.exists());
10769    }
10770
10771    #[tokio::test]
10772    async fn close_safety_check_surfaces_the_owning_window() {
10773        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10774        let svc = WorktreesService::new();
10775        // A multi-root window owns the target: the report surfaces its key and
10776        // folder count so the extension can warn "all N folders will close".
10777        svc.handle(
10778            "register",
10779            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
10780        )
10781        .await
10782        .unwrap();
10783        let report = svc
10784            .handle("close", json!({ "path": wt_path, "remove": true }))
10785            .await
10786            .unwrap();
10787        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
10788        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
10789        assert_eq!(
10790            report.get("window_folder_count").and_then(Value::as_u64),
10791            Some(2)
10792        );
10793    }
10794
10795    #[tokio::test]
10796    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
10797        let svc = WorktreesService::new();
10798        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10799            .await
10800            .unwrap();
10801        // No directive → a plain `{ known: true }`, byte-identical to before.
10802        assert_eq!(
10803            svc.handle("heartbeat", json!({ "key": "w1" }))
10804                .await
10805                .unwrap(),
10806            json!({ "known": true })
10807        );
10808        // Marked → the next heartbeat carries `close: true`, exactly once.
10809        svc.registry.mark_close_pending("w1");
10810        assert_eq!(
10811            svc.handle("heartbeat", json!({ "key": "w1" }))
10812                .await
10813                .unwrap(),
10814            json!({ "known": true, "close": true })
10815        );
10816        assert_eq!(
10817            svc.handle("heartbeat", json!({ "key": "w1" }))
10818                .await
10819                .unwrap(),
10820            json!({ "known": true })
10821        );
10822    }
10823
10824    // --- Reload op (#1417) -------------------------------------------------
10825
10826    #[tokio::test]
10827    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
10828        let svc = WorktreesService::new();
10829        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10830            .await
10831            .unwrap();
10832        // Nothing pending → `reload` is absent, so a companion that predates
10833        // #1417 sees a byte-identical reply.
10834        assert_eq!(
10835            svc.handle("heartbeat", json!({ "key": "w1" }))
10836                .await
10837                .unwrap(),
10838            json!({ "known": true })
10839        );
10840        // Marked → the next heartbeat carries `reload: true`, exactly once.
10841        svc.registry.mark_reload_pending("w1");
10842        assert_eq!(
10843            svc.handle("heartbeat", json!({ "key": "w1" }))
10844                .await
10845                .unwrap(),
10846            json!({ "known": true, "reload": true })
10847        );
10848        assert_eq!(
10849            svc.handle("heartbeat", json!({ "key": "w1" }))
10850                .await
10851                .unwrap(),
10852            json!({ "known": true })
10853        );
10854    }
10855
10856    #[tokio::test]
10857    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
10858        let svc = WorktreesService::new();
10859        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10860            .await
10861            .unwrap();
10862        // Independent `if`s, not an `else`: both fields ride the same reply and
10863        // both are consumed, so neither directive can be stranded by the other.
10864        // The companion resolves the collision by checking `close` first.
10865        svc.registry.mark_close_pending("w1");
10866        svc.registry.mark_reload_pending("w1");
10867        assert_eq!(
10868            svc.handle("heartbeat", json!({ "key": "w1" }))
10869                .await
10870                .unwrap(),
10871            json!({ "known": true, "close": true, "reload": true })
10872        );
10873        assert_eq!(
10874            svc.handle("heartbeat", json!({ "key": "w1" }))
10875                .await
10876                .unwrap(),
10877            json!({ "known": true })
10878        );
10879    }
10880
10881    #[tokio::test]
10882    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
10883        let svc = WorktreesService::new();
10884        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10885            .await
10886            .unwrap();
10887        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
10888            .await
10889            .unwrap();
10890
10891        // A key with no live window is reported, never an error: a window
10892        // closing between the client listing and sending is routine.
10893        let reply = svc
10894            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
10895            .await
10896            .unwrap();
10897        assert_eq!(
10898            reply,
10899            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
10900        );
10901
10902        // Both live targets now have a directive waiting; the unknown one does
10903        // not (the daemon must not resurrect a key it never knew).
10904        assert!(svc.registry.take_reload_pending("w1"));
10905        assert!(svc.registry.take_reload_pending("w2"));
10906        assert!(!svc.registry.take_reload_pending("ghost"));
10907    }
10908
10909    #[tokio::test]
10910    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
10911        let svc = WorktreesService::new();
10912        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10913            .await
10914            .unwrap();
10915
10916        // A client repeating a key asks for one reload, not two — `requested`
10917        // counts distinct targets so the client's summary cannot overstate.
10918        assert_eq!(
10919            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
10920                .await
10921                .unwrap(),
10922            json!({ "requested": 1, "signalled": 1, "unknown": [] })
10923        );
10924
10925        // An empty batch is a no-op success, and a missing field is an empty
10926        // batch — the callers filter their targets before sending.
10927        assert_eq!(
10928            svc.handle("reload", json!({ "target_keys": [] }))
10929                .await
10930                .unwrap(),
10931            json!({ "requested": 0, "signalled": 0, "unknown": [] })
10932        );
10933        assert_eq!(
10934            svc.handle("reload", json!({})).await.unwrap(),
10935            json!({ "requested": 0, "signalled": 0, "unknown": [] })
10936        );
10937    }
10938
10939    #[tokio::test]
10940    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
10941        let svc = WorktreesService::new();
10942        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10943            .await
10944            .unwrap();
10945        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
10946            .await
10947            .unwrap();
10948
10949        // The end-to-end contract: `reload` marks, the target's own heartbeat
10950        // delivers. Unlike `close`, nothing waits — the op has already returned.
10951        svc.handle("reload", json!({ "target_keys": ["w2"] }))
10952            .await
10953            .unwrap();
10954        assert_eq!(
10955            svc.handle("heartbeat", json!({ "key": "w2" }))
10956                .await
10957                .unwrap(),
10958            json!({ "known": true, "reload": true })
10959        );
10960        // A window that was not a target is untouched.
10961        assert_eq!(
10962            svc.handle("heartbeat", json!({ "key": "w1" }))
10963                .await
10964                .unwrap(),
10965            json!({ "known": true })
10966        );
10967    }
10968
10969    #[tokio::test]
10970    async fn reload_op_treats_an_unregistered_window_as_unknown() {
10971        let svc = WorktreesService::new();
10972        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
10973            .await
10974            .unwrap();
10975        svc.handle("unregister", json!({ "key": "w1" }))
10976            .await
10977            .unwrap();
10978        // `list()` reaps on read, so a window that has gone away cannot be
10979        // signalled — it is reported instead.
10980        assert_eq!(
10981            svc.handle("reload", json!({ "target_keys": ["w1"] }))
10982                .await
10983                .unwrap(),
10984            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
10985        );
10986    }
10987
10988    #[tokio::test]
10989    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
10990        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10991        let svc = Arc::new(WorktreesService::new());
10992        // A *different* window (not the requester) owns the target.
10993        svc.handle(
10994            "register",
10995            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
10996        )
10997        .await
10998        .unwrap();
10999
11000        // Drive the destructive close concurrently: it marks w2 to close and
11001        // waits for it to unregister before removing.
11002        let svc2 = svc.clone();
11003        let path = wt_path.clone();
11004        let close = tokio::spawn(async move {
11005            svc2.handle(
11006                "close",
11007                json!({
11008                    "path": path,
11009                    "remove": true,
11010                    "confirmed": true,
11011                    "requester_key": "w1",
11012                }),
11013            )
11014            .await
11015        });
11016
11017        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
11018        // closes its window and unregisters. Poll until the directive appears.
11019        let mut saw_close = false;
11020        for _ in 0..200 {
11021            let hb = svc
11022                .handle("heartbeat", json!({ "key": "w2" }))
11023                .await
11024                .unwrap();
11025            if hb.get("close").and_then(Value::as_bool) == Some(true) {
11026                saw_close = true;
11027                svc.handle("unregister", json!({ "key": "w2" }))
11028                    .await
11029                    .unwrap();
11030                break;
11031            }
11032            tokio::time::sleep(Duration::from_millis(5)).await;
11033        }
11034        assert!(saw_close, "w2 should have been told to close");
11035
11036        // Once w2 has unregistered, the close op removes the worktree.
11037        let reply = close.await.unwrap().unwrap();
11038        assert_eq!(reply, json!({ "removed": true }));
11039        assert!(!wt_path.exists());
11040    }
11041
11042    #[tokio::test]
11043    async fn await_windows_closed_times_out_when_a_window_never_closes() {
11044        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11045        let svc = WorktreesService::new();
11046        svc.handle(
11047            "register",
11048            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11049        )
11050        .await
11051        .unwrap();
11052        // The owning window never unregisters: the wait gives up (with a short
11053        // timeout here) rather than block, and names the still-open window.
11054        let err = await_windows_closed(
11055            &svc.registry,
11056            &wt_path,
11057            Some("w1"),
11058            Duration::from_millis(150),
11059            Duration::from_millis(25),
11060        )
11061        .await
11062        .unwrap_err();
11063        assert!(
11064            err.to_string().contains("w2"),
11065            "error names the window: {err}"
11066        );
11067        // The requester itself is excluded, so a self-only owner returns at once.
11068        await_windows_closed(
11069            &svc.registry,
11070            &wt_path,
11071            Some("w2"),
11072            Duration::from_millis(150),
11073            Duration::from_millis(25),
11074        )
11075        .await
11076        .unwrap();
11077    }
11078
11079    #[tokio::test]
11080    async fn close_window_without_remove_replies_closed_and_never_deletes() {
11081        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11082        let svc = WorktreesService::new();
11083        // "Close Window" on the main tree: no git inspection, no removal.
11084        let reply = svc
11085            .handle("close", json!({ "path": main.path(), "remove": false }))
11086            .await
11087            .unwrap();
11088        assert_eq!(reply, json!({ "closed": true }));
11089        assert!(main.path().exists());
11090    }
11091
11092    #[tokio::test]
11093    async fn close_safety_check_flags_modified_tracked_files() {
11094        // A tracked file, checked out into the linked worktree, then modified —
11095        // its content is lost on removal, so it is a `dirty` risk (distinct from
11096        // the untracked case).
11097        let main_dir = tempfile::tempdir().unwrap();
11098        let repo = init_repo(main_dir.path());
11099        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
11100        repo.set_head("refs/heads/trunk").unwrap();
11101        let wt_parent = tempfile::tempdir().unwrap();
11102        let wt_path = wt_parent.path().join("feature-wt");
11103        add_worktree(&repo, a, &wt_path, "feature");
11104        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
11105
11106        let svc = WorktreesService::new();
11107        let report = svc
11108            .handle("close", json!({ "path": wt_path, "remove": true }))
11109            .await
11110            .unwrap();
11111        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11112        assert!(
11113            risks
11114                .iter()
11115                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
11116            "expected a dirty risk: {report}"
11117        );
11118    }
11119
11120    #[tokio::test]
11121    async fn close_safety_check_flags_an_in_progress_operation() {
11122        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
11123        // non-Clean (interrupted merge) state — its progress is lost on removal.
11124        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11125        let wt_repo = Repository::open(&wt_path).unwrap();
11126        let head = wt_repo.head().unwrap().target().unwrap();
11127        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
11128        assert_ne!(wt_repo.state(), RepositoryState::Clean);
11129
11130        let svc = WorktreesService::new();
11131        let report = svc
11132            .handle("close", json!({ "path": wt_path, "remove": true }))
11133            .await
11134            .unwrap();
11135        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11136        assert!(
11137            risks
11138                .iter()
11139                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
11140            "expected an in-progress risk: {report}"
11141        );
11142    }
11143
11144    #[tokio::test]
11145    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
11146        // A linked worktree on `feature`, which tracks `origin/feature` and is one
11147        // commit ahead. The unpushed commit is INFO (the branch — and thus the
11148        // commit — survives removal), never a blocking risk.
11149        let main_dir = tempfile::tempdir().unwrap();
11150        let repo = init_repo(main_dir.path());
11151        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11152        repo.set_head("refs/heads/trunk").unwrap();
11153        let a_commit = repo.find_commit(a).unwrap();
11154        repo.branch("feature", &a_commit, false).unwrap();
11155        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
11156            .unwrap();
11157        // `feature` advances one commit past `origin/feature`.
11158        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
11159        drop(a_commit);
11160        let mut cfg = repo.config().unwrap();
11161        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
11162            .unwrap();
11163        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
11164            .unwrap();
11165        cfg.set_str("branch.feature.remote", "origin").unwrap();
11166        cfg.set_str("branch.feature.merge", "refs/heads/feature")
11167            .unwrap();
11168        // A worktree on the existing `feature` branch (not created fresh, so it
11169        // keeps the ahead-of-upstream divergence).
11170        let wt_parent = tempfile::tempdir().unwrap();
11171        let wt_path = wt_parent.path().join("feature-wt");
11172        let reference = repo.find_reference("refs/heads/feature").unwrap();
11173        let mut opts = git2::WorktreeAddOptions::new();
11174        opts.reference(Some(&reference));
11175        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
11176
11177        let svc = WorktreesService::new();
11178        let report = svc
11179            .handle("close", json!({ "path": wt_path, "remove": true }))
11180            .await
11181            .unwrap();
11182        // Unpushed commits appear as `info`, and the worktree is still cleanly
11183        // removable with no blocking risks.
11184        let info = report.get("info").and_then(Value::as_array).unwrap();
11185        assert!(
11186            info.iter()
11187                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
11188            "expected an unpushed info note: {report}"
11189        );
11190        assert!(
11191            report
11192                .get("risks")
11193                .and_then(Value::as_array)
11194                .unwrap()
11195                .is_empty(),
11196            "unpushed commits alone must not block: {report}"
11197        );
11198        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11199    }
11200
11201    #[tokio::test]
11202    async fn close_safety_check_ignores_gitignored_files() {
11203        // With `.gitignore` committed, an ignored artifact is the only worktree
11204        // change — it must not count as untracked (it is regenerable), so the
11205        // worktree stays cleanly removable with no risks.
11206        let main_dir = tempfile::tempdir().unwrap();
11207        let repo = init_repo(main_dir.path());
11208        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
11209        repo.set_head("refs/heads/trunk").unwrap();
11210        let wt_parent = tempfile::tempdir().unwrap();
11211        let wt_path = wt_parent.path().join("feature-wt");
11212        add_worktree(&repo, a, &wt_path, "feature");
11213        std::fs::create_dir(wt_path.join("build")).unwrap();
11214        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
11215
11216        let svc = WorktreesService::new();
11217        let report = svc
11218            .handle("close", json!({ "path": wt_path, "remove": true }))
11219            .await
11220            .unwrap();
11221        assert!(
11222            report
11223                .get("risks")
11224                .and_then(Value::as_array)
11225                .unwrap()
11226                .is_empty(),
11227            "a gitignored file must not create a risk: {report}"
11228        );
11229        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11230    }
11231
11232    #[tokio::test]
11233    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
11234        // The phase-1 check on a path that no longer exists reports it removable
11235        // with no risks (so the idempotent execute proceeds with no dialog).
11236        let svc = WorktreesService::new();
11237        let report = svc
11238            .handle(
11239                "close",
11240                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
11241            )
11242            .await
11243            .unwrap();
11244        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11245        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
11246        assert!(report
11247            .get("risks")
11248            .and_then(Value::as_array)
11249            .unwrap()
11250            .is_empty());
11251        let info = report.get("info").and_then(Value::as_array).unwrap();
11252        assert!(info
11253            .iter()
11254            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
11255    }
11256
11257    #[tokio::test]
11258    async fn close_phase1_errors_on_a_non_git_worktree_path() {
11259        // An existing directory that is *not* a git worktree makes `git_safety`
11260        // fail; the error must propagate (rather than delete an unknown dir),
11261        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
11262        // audit line itself is unit-tested via `log_close_error`.
11263        let dir = tempfile::tempdir().unwrap();
11264        let svc = WorktreesService::new();
11265        let result = svc
11266            .handle("close", json!({ "path": dir.path(), "remove": true }))
11267            .await;
11268        assert!(
11269            result.is_err(),
11270            "a non-git-worktree target must error the safety check, got: {result:?}"
11271        );
11272    }
11273
11274    #[tokio::test]
11275    async fn close_refuses_a_locked_worktree() {
11276        // A locked worktree (git worktree lock) must be refused, not forced past
11277        // (failure mode #6), and left on disk.
11278        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11279        let main_repo = Repository::open(main.path()).unwrap();
11280        main_repo
11281            .find_worktree("feature")
11282            .unwrap()
11283            .lock(Some("under test"))
11284            .unwrap();
11285
11286        let svc = WorktreesService::new();
11287        let err = svc
11288            .handle(
11289                "close",
11290                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11291            )
11292            .await
11293            .unwrap_err();
11294        assert!(
11295            err.to_string().contains("locked"),
11296            "expected a locked error: {err}"
11297        );
11298        assert!(wt_path.exists(), "a locked worktree must not be removed");
11299    }
11300
11301    #[tokio::test]
11302    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
11303        // A detached HEAD that still sits on a commit a branch points to loses
11304        // nothing on removal, so it must NOT produce an unreachable-commits risk
11305        // (the false-positive the reachability walk guards against).
11306        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11307        let wt_repo = Repository::open(&wt_path).unwrap();
11308        // The worktree is on `feature`; detach HEAD onto its current tip, which
11309        // the `feature` branch still references.
11310        let tip = wt_repo.head().unwrap().target().unwrap();
11311        wt_repo.set_head_detached(tip).unwrap();
11312        assert!(wt_repo.head_detached().unwrap());
11313
11314        let svc = WorktreesService::new();
11315        let report = svc
11316            .handle("close", json!({ "path": wt_path, "remove": true }))
11317            .await
11318            .unwrap();
11319        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11320        assert!(
11321            !risks
11322                .iter()
11323                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11324            "a detached HEAD reachable from a branch must not be flagged: {report}"
11325        );
11326    }
11327
11328    #[test]
11329    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
11330        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11331        let main_repo = Repository::open(main.path()).unwrap();
11332        // The real linked worktree resolves to its registered name.
11333        assert_eq!(
11334            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
11335            "feature"
11336        );
11337        // A path that is not one of this repo's worktrees is the defensive
11338        // "not registered" error (the guard behind removal).
11339        let err =
11340            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
11341        assert!(
11342            err.to_string().contains("not registered"),
11343            "expected a not-registered error: {err}"
11344        );
11345    }
11346
11347    #[test]
11348    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
11349        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
11350        // rather than sinking the whole safety check.
11351        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11352        let repo = Repository::open(&wt_path).unwrap();
11353        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
11354        // Confirm the corruption actually breaks status enumeration, so the
11355        // count is exercising the error-degradation branch (not an empty repo).
11356        assert!(
11357            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
11358            "a corrupt index should make statuses() fail"
11359        );
11360        assert_eq!(count_dirty_untracked(&repo), (0, 0));
11361    }
11362}