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::remote::RemoteInfo;
46use crate::git::worktree_batch::Selection;
47use crate::git::worktree_push;
48use crate::git::worktree_rebase;
49use crate::github_rate_limit::{
50    resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
51};
52use crate::pr_status::{
53    EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
54};
55use async_trait::async_trait;
56use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
57use serde::{Deserialize, Serialize};
58use serde_json::{json, Value};
59use tokio::sync::watch;
60use tokio::sync::Mutex as AsyncMutex;
61use tokio::task::JoinHandle;
62use tokio_util::sync::CancellationToken;
63
64use crate::daemon::service::{
65    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
66};
67use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
68
69/// The worktrees service name (the control-socket routing key).
70pub const SERVICE_NAME: &str = "worktrees";
71
72/// The tray submenu title.
73const SUBMENU_TITLE: &str = "Worktrees";
74
75/// Environment override for the VS Code launcher used by the "focus" tray
76/// action, for when the daemon runs under launchd with a minimal `PATH`.
77const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
78
79/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
80/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
81const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
82
83/// Default cadence at which the background task recomputes the tray menu snapshot
84/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
85/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
86/// the GUI thread (which would peg a core and stall shutdown — the #1186
87/// regression); the interval only governs how stale that cached branch/sync state
88/// may be when the menu is opened.
89///
90/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
91/// walk that the subscription-stream coalescing (#1303) never touched — it was
92/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
93/// menu open-latency unchanged (the cache still serves instantly).
94const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
95
96/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
97/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
98fn menu_refresh_interval() -> Duration {
99    crate::daemon::server::duration_secs_from_env(
100        ENV_MENU_REFRESH_INTERVAL,
101        DEFAULT_MENU_REFRESH_INTERVAL,
102    )
103}
104
105/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
106/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
107/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
108const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
109
110/// Default cadence for the PR badge poller while CI is in flight (#1337).
111///
112/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
113/// watching a run — which is exactly this situation. It is affordable because the
114/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
115/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
116/// while something is actually pending.
117const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
118
119/// The ceiling the poller backs off to once every badge is terminal.
120///
121/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
122/// The 30-minute figure is the cross-tool consensus for background PR polling
123/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
124/// defaults). The backoff exists for battery and wakeups rather than budget — at
125/// 1 point per poll the budget never binds.
126const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
127
128/// How long after fresh work (a push, or an added target) the poller holds its
129/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
130/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
131/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
132/// is watched more cheaply. See [`next_pr_poll_delay`].
133const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
134
135/// The ceiling the poller escalates to *while still pending* once
136/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
137/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
138/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
139/// at ~50 calls instead of ~120.
140const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
141
142/// The cadence floor the poller is held to while the shared GitHub budget is
143/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
144/// Five minutes makes the daemon's contribution to an already-strained budget
145/// negligible while still recovering promptly once the window resets — a pause in
146/// all but name. See [`budget_throttled_delay`].
147const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
148
149/// Environment override for [`pr_debounce_interval`] — the settle window the PR
150/// poller waits after the change-notify fires before snapshotting (whole seconds;
151/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
152const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
153
154/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
155///
156/// A VS Code restart unregisters then re-registers its windows one-by-one over
157/// several seconds, each bump waking the poller; a daemon restart re-registers the
158/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
159/// storm into **one** fetch on the final watch set instead of one per window.
160const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
161
162/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
163/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
164fn pr_poll_interval() -> Duration {
165    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
166}
167
168/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
169/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
170fn pr_debounce_interval() -> Duration {
171    crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
172}
173
174/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
175/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
176/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
177const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
178
179/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
180/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
181/// the extension's transient badge fallback — from the daemon means N windows now
182/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
183const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
184
185/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
186/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
187/// `PullRequest` shape unchanged.
188const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
189
190/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
191/// (parity with the extension's `PR_LIST_LIMIT`).
192const OPEN_PR_LIST_LIMIT: &str = "100";
193
194/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
195/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
196fn open_pr_ttl() -> Duration {
197    crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
198}
199
200/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
201/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
202/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
203const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
204
205/// Default cadence for the GitHub rate-limit monitor (#1375).
206///
207/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
208/// spends nothing against any budget — so unlike the PR poller this cadence has no
209/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
210/// keeps `daemon status` current enough to catch a slow drain.
211const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
212
213/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
214/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
215fn rate_limit_poll_interval() -> Duration {
216    crate::daemon::server::duration_secs_from_env(
217        ENV_RATE_LIMIT_POLL_INTERVAL,
218        DEFAULT_RATE_LIMIT_POLL_INTERVAL,
219    )
220}
221
222/// A running background menu-refresh task and the token that stops it.
223struct RefreshTask {
224    /// Cancelled by `shutdown` to end the refresh loop.
225    token: CancellationToken,
226    /// The spawned loop, awaited on shutdown so it fully unwinds.
227    handle: JoinHandle<()>,
228}
229
230/// Whether this tick should spend a `gh` call.
231///
232/// The poller wakes far more often than it fetches — waking is a cached snapshot
233/// read, fetching is a subprocess and a network round trip. Two things justify the
234/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
235/// a push, invisible to the change-notify, so only looking finds it), or the
236/// **backoff elapsed** and it is simply time to look again.
237///
238/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
239/// worktree going away, a lease lapsing, or a TTL reap can never change any
240/// **surviving** badge, so `grew` is false and the poll skips — see
241/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
242///
243/// Pure so the policy is testable directly: from outside, the only evidence of it
244/// is *when* a subprocess runs, which a test cannot pin down without either flaking
245/// or passing for the wrong reason.
246fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
247    grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
248}
249
250/// Whether `next` holds a watch the poller has not already resolved for its
251/// current upstream — an **addition** (a new (repo, branch) target) or an
252/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
253/// reports). Either warrants asking GitHub *now*.
254///
255/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
256/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
257/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
258/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
259/// exactly the cached verdict, and the badge stays correctly stale through
260/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
261/// network call (#1389, fix 3).
262///
263/// Pure so the fetch trigger is testable without driving a live subprocess.
264fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
265    next.iter().any(|w| !prev.contains(w))
266}
267
268/// The next PR-poll delay.
269///
270/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
271///   — nothing is expected to change, so this is a slow liveness heartbeat. A failed
272///   poll passes `pending: false`, so a persistent failure backs off here rather
273///   than being retried hard.
274/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
275///   [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
276///   `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
277///   run used to pin `base` for its whole duration (360 calls/hour); escalating
278///   *within* pending caps that while a verdict arriving a cadence-tick late stays
279///   invisible in the tray. `since_moved` is time since fresh work was last seen (a
280///   push or an added target), or `None` when nothing has moved yet — treated as
281///   past the fast window so a stale-from-boot pending state does not pin `base`.
282///
283/// A pure function rather than copies inline, because the cadence is only
284/// observable from outside as timing, which a test cannot assert without flaking.
285fn next_pr_poll_delay(
286    current: Duration,
287    base: Duration,
288    pending: bool,
289    since_moved: Option<Duration>,
290) -> Duration {
291    if !pending {
292        return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
293    }
294    match since_moved {
295        // Fresh work: watch it closely at `base` while it is likely to resolve.
296        Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
297        // Still pending well after the move (a long CI run, or a zombie suite):
298        // escalate so the cadence stops burning, bounded below the terminal ceiling.
299        _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
300    }
301}
302
303/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
304///
305/// The daemon is the **single** `gh` choke point for every open window, so this is
306/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
307/// any tracked resource is at/over
308/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
309/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
310/// the budget the whole machine shares — structurally, not just by convention. Below
311/// the threshold (or with no reading yet) the delay is returned unchanged.
312///
313/// Pure so the throttle is testable without a live rate-limit poll.
314fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
315    if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
316        delay.max(BUDGET_THROTTLE_INTERVAL)
317    } else {
318        delay
319    }
320}
321
322/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
323/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
324/// threshold upward since the previous reading (or is already over on the first
325/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
326/// per crossing rather than every poll while usage stays high.
327///
328/// Pure so the policy is testable without driving a live poll.
329fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
330    let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
331    // Per-resource so a *different* resource crossing (while another recovers) is
332    // still caught — `graphql` dropping below while `core` climbs over would look
333    // unchanged to a whole-snapshot `over_warn` comparison.
334    [
335        (prev.and_then(|p| p.graphql), next.graphql),
336        (prev.and_then(|p| p.core), next.core),
337        (prev.and_then(|p| p.search), next.search),
338    ]
339    .into_iter()
340    .any(|(before, after)| over(after) && !over(before))
341}
342
343/// A running background PR-badge poll task and the token that stops it.
344struct PollerTask {
345    /// Cancelled by `shutdown` to end the poll loop.
346    token: CancellationToken,
347    /// The spawned loop, awaited on shutdown so it fully unwinds.
348    handle: JoinHandle<()>,
349}
350
351/// One thing the PR poller watches: a badge target and the commit its upstream
352/// points at.
353///
354/// The upstream OID is what makes a **push** observable to the poller. A window
355/// opening bumps the registry's change-notify, but nothing notifies the daemon
356/// when you push — so the poller compares this against the previous tick's and
357/// treats an added target or a moved upstream as "go and ask now" (see
358/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
359/// very thing that starts the CI run a badge reports, so the upstream must be here.
360///
361/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
362/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
363/// call, and the badge stays correctly stale through
364/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
365/// comparison, no network — until the branch is actually pushed. Equality is thus
366/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
367#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
368struct PrWatch {
369    /// The (repo, branch) to resolve a badge for.
370    target: PrTarget,
371    /// That branch's upstream tip, or `None` when it tracks no upstream.
372    upstream_sha: Option<String>,
373}
374
375/// Extracts what the poller watches — the badge targets and their local heads and
376/// upstream tips — from a `tree` snapshot.
377///
378/// Reading them back off the snapshot — rather than walking git again — means the
379/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
380/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
381/// out of its way to remove. Only GitHub repos with a branch contribute; the result
382/// is sorted and deduped so N worktrees of one repo on one branch ask once.
383fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
384    let mut out = Vec::new();
385    for repo in snapshot
386        .get("repos")
387        .and_then(Value::as_array)
388        .into_iter()
389        .flatten()
390    {
391        // The zero-`gh` guarantee (#1376): a repo the user has not enabled
392        // contributes no watch, so the poll's `gh api graphql` never mentions it.
393        // The snapshot this reads is already `stamp_polling`-stamped, so this one
394        // check is the single filter point — default-off means an absent flag skips.
395        if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
396            continue;
397        }
398        let Some(github) = repo.get("github") else {
399            continue;
400        };
401        let (Some(owner), Some(name)) = (
402            github.get("owner").and_then(Value::as_str),
403            github.get("name").and_then(Value::as_str),
404        ) else {
405            continue;
406        };
407        for wt in repo
408            .get("worktrees")
409            .and_then(Value::as_array)
410            .into_iter()
411            .flatten()
412        {
413            if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
414                out.push(PrWatch {
415                    upstream_sha: wt
416                        .get("upstream_sha")
417                        .and_then(Value::as_str)
418                        .map(str::to_string),
419                    target: PrTarget {
420                        owner: owner.to_string(),
421                        name: name.to_string(),
422                        branch: branch.to_string(),
423                    },
424                });
425            }
426        }
427    }
428    out.sort();
429    out.dedup();
430    out
431}
432
433/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
434/// without the heads.
435#[cfg(test)]
436fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
437    pr_watch_from_snapshot(snapshot)
438        .into_iter()
439        .map(|w| w.target)
440        .collect()
441}
442
443/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
444pub struct WorktreesService {
445    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
446    /// the background menu-refresh task can read it off the main thread.
447    registry: Arc<WorktreesRegistry>,
448    /// The most recent tray menu snapshot, recomputed off the main thread by
449    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
450    /// of this so it never blocks on git enrichment. `None` until the first
451    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
452    /// which case `menu()` falls back to a one-off inline compute.
453    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
454    /// The background refresh task, once started (`None` in tests / no runtime).
455    refresh: Mutex<Option<RefreshTask>>,
456    /// PR badges resolved by the background poller and read by the tree snapshot
457    /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
458    /// share the one cache. Empty until the first poll lands — and always empty
459    /// when no poller runs (unit tests), in which case the tree simply carries no
460    /// `pr` field, exactly as a pre-#1337 daemon did.
461    pr_cache: Arc<PrStatusCache>,
462    /// The background PR-badge poll task, once started (`None` in tests / no
463    /// runtime).
464    poller: Mutex<Option<PollerTask>>,
465    /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
466    /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
467    /// poll task, the tray refresh, and the daemon's registry share the one cache.
468    /// Empty until the first poll lands; the daemon hands a clone to the registry
469    /// so `status` can report machine-wide GitHub budget usage.
470    ///
471    /// [`rate-limit poller`]: Self::start_rate_limit_poller
472    rate_limit_cache: Arc<RateLimitCache>,
473    /// The background rate-limit poll task, once started (`None` in tests / no
474    /// runtime).
475    rate_limit_poller: Mutex<Option<PollerTask>>,
476    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
477    /// through, so N open windows perform **one** `build_tree` per tick instead
478    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
479    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
480    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
481    tree_cache: Arc<TreeSnapshotCache>,
482    /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
483    ///
484    /// The extension fans a multi-select delete out into one `close` op per
485    /// target, so two executes can reach the prune at once. Their heartbeat waits
486    /// overlap freely — that is the point — but the prunes themselves should not:
487    /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
488    /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
489    /// read a directory a sibling is midway through removing from, and `git2`
490    /// promises nothing about that. Precautionary rather than a fix for an
491    /// observed corruption — the window is narrow enough that it has not been
492    /// reproduced — but serializing costs nothing measurable (the prune is a
493    /// directory delete; the wait it follows is seconds) and keeps the fan-out
494    /// safe at the source rather than relying on every caller to stay sequential.
495    ///
496    /// A `tokio` mutex rather than a `std` one: it is held across the
497    /// `spawn_blocking` join, which is an `.await`.
498    prune_lock: tokio::sync::Mutex<()>,
499    /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
500    /// choice survives a daemon restart. `None` disables persistence entirely —
501    /// the default from [`new`](Self::new), which keeps the bare service cheap
502    /// and I/O-free for unit tests; the daemon wires it via
503    /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
504    /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
505    /// briefly and never held across an `.await`.
506    polling_prefs_path: Mutex<Option<PathBuf>>,
507    /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
508    /// survive a daemon restart and the poller can skip its immediate re-poll when
509    /// they are still fresh. `None` disables persistence — the default from
510    /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
511    /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
512    /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
513    pr_cache_path: Mutex<Option<PathBuf>>,
514    /// The warm-start state restored from the persisted cache (#1389, fix 4),
515    /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
516    /// spawns. `None` on a cold start (no file, or persistence disabled), in which
517    /// case the poller does its normal first fetch.
518    pr_warm_start: Mutex<Option<PrWarmStart>>,
519    /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
520    /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
521    /// dedupe to one counted `gh` per repo instead of one per window. Behind an
522    /// `Arc` for parity with the other caches.
523    open_pr_cache: Arc<OpenPrCache>,
524    /// Where the windows the **last** `reposition` moved were sitting beforehand,
525    /// so `reposition-undo` can put them back (#1407).
526    ///
527    /// Exactly one level of undo, deliberately: the affordance exists because
528    /// repositioning is otherwise irreversible (the previous layout is simply
529    /// gone), and "undo the thing I just did" is the whole of what that needs. A
530    /// deeper stack would raise questions — what does undoing an older batch mean
531    /// once a newer one has moved the same window? — that a one-level store cannot
532    /// pose. Each successful `reposition` replaces it; each `reposition-undo`
533    /// consumes it, so an undo cannot be replayed.
534    ///
535    /// In-memory only, like every other piece of registry state: a daemon restart
536    /// drops it, and the user is simply left with the layout they have. Behind its
537    /// **own** `std::Mutex`, taken independently of the registry's (neither nests)
538    /// and never held across an `.await`.
539    reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
540    /// Serializes the `rebase` op's phase-2 execute across concurrent requests
541    /// (#1415) — the [`prune_lock`](Self::prune_lock) precedent, one op over.
542    ///
543    /// Within a batch the engine already rebases sequentially, on purpose: linked
544    /// worktrees share one object database, and `git rebase` writes refs and
545    /// packs into it. Two *concurrent requests* would defeat that, so the lock
546    /// restores it globally. It also means a second click cannot start a rebase of
547    /// a worktree the first is still mid-way through — the `operation`-in-progress
548    /// classifier only sees state that is already on disk.
549    ///
550    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
551    rebase_lock: tokio::sync::Mutex<()>,
552    /// Serializes the `push` op's phase-2 execute across concurrent requests
553    /// (#1443) — the [`rebase_lock`](Self::rebase_lock) twin.
554    ///
555    /// A successful push writes `refs/remotes/<remote>/<branch>` into the object
556    /// database and ref store that every linked worktree of the repository shares,
557    /// so concurrent batches would race on it. Taking the lock **before** the
558    /// re-plan (not merely around the execute) is what keeps the classification the
559    /// engine acts on from being invalidated by another batch's push mid-flight —
560    /// which for a lease is not benign: a plan taken outside the lock could still
561    /// say `would-force` against a tracking ref the other run has since advanced.
562    ///
563    /// Deliberately **separate** from `rebase_lock` rather than shared: a rebase and
564    /// a push touch different refs (`refs/heads/*` vs `refs/remotes/*`) and there is
565    /// no reason a push should wait behind an unrelated repository's rebase.
566    ///
567    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
568    push_lock: tokio::sync::Mutex<()>,
569}
570
571impl WorktreesService {
572    /// Creates the service with an empty registry. Cheap — no I/O and no task;
573    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
574    /// off-thread menu caching, while tests use the bare service (menu computed
575    /// inline on demand).
576    #[must_use]
577    pub fn new() -> Self {
578        let registry = Arc::new(WorktreesRegistry::new());
579        let pr_cache = Arc::new(PrStatusCache::new());
580        Self {
581            registry: registry.clone(),
582            menu_cache: Arc::new(Mutex::new(None)),
583            refresh: Mutex::new(None),
584            pr_cache: pr_cache.clone(),
585            poller: Mutex::new(None),
586            rate_limit_cache: Arc::new(RateLimitCache::new()),
587            rate_limit_poller: Mutex::new(None),
588            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
589            prune_lock: tokio::sync::Mutex::new(()),
590            polling_prefs_path: Mutex::new(None),
591            pr_cache_path: Mutex::new(None),
592            pr_warm_start: Mutex::new(None),
593            open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
594            reposition_undo: Mutex::new(Vec::new()),
595            rebase_lock: tokio::sync::Mutex::new(()),
596            push_lock: tokio::sync::Mutex::new(()),
597        }
598    }
599
600    /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
601    /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
602    /// back to it (#1376). Called once by the daemon at startup, before any window
603    /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
604    /// bump. Best-effort throughout: a missing file is the first-run default (no
605    /// repos enabled), and a corrupt/unreadable one is logged and treated as
606    /// empty rather than wedging the service — the user simply re-enables. The
607    /// path is stored regardless, so the next change rewrites a clean file.
608    pub fn load_polling_prefs(&self, path: PathBuf) {
609        match std::fs::read(&path) {
610            Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
611                Ok(prefs) => self
612                    .registry
613                    .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
614                Err(err) => tracing::warn!(
615                    "ignoring unreadable worktrees polling prefs at {}: {err:#}",
616                    path.display()
617                ),
618            },
619            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
620            Err(err) => tracing::warn!(
621                "could not read worktrees polling prefs at {}: {err:#}",
622                path.display()
623            ),
624        }
625        *self
626            .polling_prefs_path
627            .lock()
628            .unwrap_or_else(PoisonError::into_inner) = Some(path);
629    }
630
631    /// Writes the current enable set to the `0600` prefs file, if persistence is
632    /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
633    /// Best-effort: a write failure is logged at WARN and swallowed, since the
634    /// in-memory set is authoritative for the running daemon — the user's toggle
635    /// still took effect, it just would not survive a restart. A no-op (returns
636    /// early) in unit tests, which never configure a path.
637    fn persist_polling_prefs(&self) {
638        let Some(path) = self
639            .polling_prefs_path
640            .lock()
641            .unwrap_or_else(PoisonError::into_inner)
642            .clone()
643        else {
644            return;
645        };
646        let prefs = PollingPrefs {
647            enabled: self
648                .registry
649                .polling_snapshot()
650                .into_iter()
651                .map(|(repo, expires_at)| PollingLease { repo, expires_at })
652                .collect(),
653        };
654        if let Err(err) = write_polling_prefs(&path, &prefs) {
655            tracing::warn!(
656                "could not persist worktrees polling prefs to {}: {err:#}",
657                path.display()
658            );
659        }
660    }
661
662    /// Seeds the resolved PR-badge cache from the persisted `0600` file and
663    /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
664    /// once by the daemon at startup, before any window subscribes and before the
665    /// poller spawns, so restored badges render on the first tree snapshot and the
666    /// poller can skip its immediate re-poll for verdicts still fresh.
667    ///
668    /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
669    /// contract): a missing file is the cold-start default, and a corrupt/unreadable
670    /// one is logged and treated as empty — the poller simply re-resolves. The path
671    /// is stored regardless, so the next poll rewrites a clean file. Restores both
672    /// the badges (into [`pr_cache`](Self::pr_cache)) and the
673    /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
674    pub fn load_pr_cache(&self, path: PathBuf) {
675        match std::fs::read(&path) {
676            Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
677                Ok(prefs) => {
678                    self.pr_cache.seed(
679                        prefs
680                            .entries
681                            .into_iter()
682                            .map(|e| (e.target, e.resolution.into_resolution())),
683                    );
684                    // A warm start needs both a watch set to compare against and a
685                    // poll time to age it; without `polled_at` the file is too old a
686                    // shape to trust, so treat it as a cold start (badges still
687                    // render, the poller just re-polls immediately).
688                    if let Some(polled_at) = prefs.polled_at {
689                        let watched = prefs
690                            .watched
691                            .into_iter()
692                            .map(|w| PrWatch {
693                                target: w.target,
694                                upstream_sha: w.upstream_sha,
695                            })
696                            .collect();
697                        *self
698                            .pr_warm_start
699                            .lock()
700                            .unwrap_or_else(PoisonError::into_inner) =
701                            Some(PrWarmStart { watched, polled_at });
702                    }
703                }
704                // Bind the path so it is formatted whenever the branch runs — not
705                // only when a WARN subscriber is installed — so coverage sees it
706                // (the `let summary = …` pattern the rate-limit warn uses).
707                Err(err) => {
708                    let at = path.display();
709                    tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
710                }
711            },
712            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
713            Err(err) => {
714                let at = path.display();
715                tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
716            }
717        }
718        *self
719            .pr_cache_path
720            .lock()
721            .unwrap_or_else(PoisonError::into_inner) = Some(path);
722    }
723
724    /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
725    /// served from the shared TTL cache when fresh, else **one** counted `gh pr
726    /// list`. The `gh` runs on a blocking thread (never an async worker), routed
727    /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
728    /// point so the call is still counted exactly once — the constraint the whole
729    /// of #1389 preserves. The result is forwarded to the extension verbatim.
730    async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
731        // The `gh` binary is resolved once here (the env read is process-stable),
732        // then handed to the seam below — the poller's "bin as a param" pattern, so
733        // a test injects a stub without mutating the process environment (#1030).
734        self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
735            .await
736    }
737
738    /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
739    /// the cache against a stub without touching the environment.
740    async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
741        let key = format!("{owner}/{name}");
742        if let Some(prs) = self.open_pr_cache.fresh(&key) {
743            return Ok(prs);
744        }
745        let slug = key.clone();
746        let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
747            .await
748            .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
749        self.open_pr_cache.store(key, prs.clone());
750        Ok(prs)
751    }
752
753    /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
754    /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
755    /// for the built-in `status` op to read.
756    #[must_use]
757    pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
758        self.rate_limit_cache.clone()
759    }
760
761    /// Starts the background task that recomputes the tray menu snapshot every
762    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
763    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
764    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
765    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
766    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
767    /// keep computing the menu inline.
768    pub fn start_menu_refresh(&self) {
769        if tokio::runtime::Handle::try_current().is_err() {
770            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
771            return;
772        }
773        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
774        if guard.is_some() {
775            return;
776        }
777        let token = CancellationToken::new();
778        let loop_token = token.clone();
779        let registry = self.registry.clone();
780        let cache = self.menu_cache.clone();
781        let rate_limit_cache = self.rate_limit_cache.clone();
782        // Resolved once at spawn: the interval is process-stable env config, and
783        // re-reading it every loop would be wasted work.
784        let interval = menu_refresh_interval();
785        let handle = tokio::spawn(async move {
786            loop {
787                // Snapshot the registry (a cheap lock), then build the menu —
788                // which opens repos and parses git config — on a blocking thread,
789                // never on this async worker or the tray's main thread.
790                let entries = registry.list();
791                // The rate-limit reading (a cheap lock, `Copy`) prepends a status
792                // line; read it here and hand it to the blocking build (#1375).
793                let rate_limit = rate_limit_cache.get();
794                if let Ok(items) = tokio::task::spawn_blocking(move || {
795                    menu_items_for(&entries, rate_limit.as_ref())
796                })
797                .await
798                {
799                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
800                }
801                tokio::select! {
802                    () = loop_token.cancelled() => break,
803                    () = tokio::time::sleep(interval) => {}
804                }
805            }
806        });
807        *guard = Some(RefreshTask { token, handle });
808    }
809
810    /// Starts the background task that keeps PR check badges fresh (#1337).
811    ///
812    /// This is the half of the badge nothing else can do. Badges used to be
813    /// resolved extension-side on repo-expand, so they were recomputed only when a
814    /// repo node's children were rebuilt — and the streamed snapshot carries
815    /// worktree topology, not CI. While CI ran and no window opened or closed,
816    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
817    ///
818    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
819    /// (cost 1, independent of repo/worktree/window count), writes the cache the
820    /// tree snapshot reads, and bumps the registry's change-notify **only when a
821    /// verdict actually moved** — so the server's diff pushes to every open window
822    /// exactly when CI state changes, and never otherwise.
823    ///
824    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
825    /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
826    /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
827    /// terminal; it polls nothing at all while no window is registered.
828    ///
829    /// It spends a `gh` call only when the watch set **grows** — a target added or
830    /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
831    /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
832    /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
833    /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
834    /// 4), and the cadence is capped when the shared GitHub budget is strained
835    /// (#1389 fix 6).
836    ///
837    /// Idempotent, and a no-op outside a tokio runtime (mirroring
838    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
839    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
840    pub fn start_pr_poller(&self) {
841        // Resolved once at spawn: process-stable env config (the menu-refresh
842        // precedent), never re-read per poll.
843        self.start_pr_poller_with(
844            pr_poll_interval(),
845            pr_debounce_interval(),
846            crate::pr_status::resolve_gh_binary(),
847        );
848    }
849
850    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
851    /// debounce settle window, and `gh` binary, so tests drive the loop at
852    /// millisecond speed against a stub **without mutating the process
853    /// environment** — one global env var cannot serve two parallel tests pointing
854    /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
855    /// Snowflake heartbeat's "interval via config, not env" rule.
856    ///
857    /// Reads the rate-limit cache, the persistence path, and the warm-start state
858    /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
859    /// the original two-cadence seam.
860    fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
861        if tokio::runtime::Handle::try_current().is_err() {
862            tracing::debug!("no tokio runtime; worktrees PR poller not started");
863            return;
864        }
865        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
866        if guard.is_some() {
867            return;
868        }
869        let token = CancellationToken::new();
870        let loop_token = token.clone();
871        let registry = self.registry.clone();
872        let tree_cache = self.tree_cache.clone();
873        let pr_cache = self.pr_cache.clone();
874        // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
875        // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
876        // the loop once and must not be reused by a later restart of the poller.
877        let rate_limit_cache = self.rate_limit_cache.clone();
878        let pr_cache_path = self
879            .pr_cache_path
880            .lock()
881            .unwrap_or_else(PoisonError::into_inner)
882            .clone();
883        let warm_start = self
884            .pr_warm_start
885            .lock()
886            .unwrap_or_else(PoisonError::into_inner)
887            .take();
888        // Captured here, before the task's first sleep, so a window that registers
889        // while the loop is starting still wakes it rather than being missed.
890        let mut changes = self.registry.subscribe_changes();
891        let handle = tokio::spawn(async move {
892            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
893            // of the coalescing snapshot cache, no subprocess, no network. It only
894            // *asks GitHub* when there is reason to: the watch set grew (a target
895            // added, or an upstream pushed), or the backoff has elapsed.
896            //
897            // They have to be separate because the two things that should trigger a
898            // fetch arrive by different routes. A window opening bumps the registry's
899            // change-notify, but **a push does not** — nothing in the daemon is
900            // notified when you `git push`. The only way to notice is to look, so the
901            // loop looks often and cheaply, and pays only when something grew.
902            let mut backoff = base;
903            // Warm start (#1389, fix 4): resume what the previous daemon last
904            // resolved and when, so a restart within the backoff window skips the
905            // immediate re-poll for verdicts already restored into `pr_cache`.
906            // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
907            // reboot (monotonic epoch reset) or a future timestamp collapses to
908            // "never polled", which just re-polls — the safe direction.
909            let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
910                match warm_start {
911                    Some(ws) => {
912                        let elapsed = (Utc::now() - ws.polled_at)
913                            .to_std()
914                            .unwrap_or(Duration::ZERO);
915                        (Some(ws.watched), Instant::now().checked_sub(elapsed))
916                    }
917                    None => (None, None),
918                };
919            // When fresh work (a push or an added target) was last seen, so the
920            // pending cadence can escalate once it goes quiet (#1389, fix 5).
921            let mut moved_at: Option<Instant> = None;
922            'poll: loop {
923                // Wait first: at startup no window has registered yet, and the
924                // first snapshot would be empty anyway.
925                tokio::select! {
926                    () = loop_token.cancelled() => break,
927                    () = tokio::time::sleep(base) => {}
928                    // A window opened or closed — look now rather than at the next
929                    // tick, but debounce first.
930                    result = changes.changed() => {
931                        // Unreachable today: this task owns an `Arc` of the registry
932                        // that holds the sender, so it cannot be dropped while we are
933                        // here. Kept anyway because the alternative is worse — a
934                        // closed channel makes `changed()` return `Ready` forever, so
935                        // ignoring the error would spin this loop at full speed,
936                        // re-snapshotting and re-running `gh` every iteration.
937                        if result.is_err() {
938                            break;
939                        }
940                        // Debounce (#1389, fix 2): a VS Code restart unregisters then
941                        // re-registers its windows one-by-one over several seconds,
942                        // each bump waking us; a daemon restart re-registers the same
943                        // way. Wait for `debounce` of quiet before snapshotting so the
944                        // whole storm collapses to **one** fetch on the final watch
945                        // set. Bounded by an overall deadline so a steady drip of
946                        // changes cannot postpone the poll forever.
947                        let overall_deadline = Instant::now() + debounce.saturating_mul(4);
948                        loop {
949                            tokio::select! {
950                                () = loop_token.cancelled() => break 'poll,
951                                () = tokio::time::sleep(debounce) => break,
952                                r = changes.changed() => {
953                                    if r.is_err() {
954                                        break 'poll;
955                                    }
956                                    if Instant::now() >= overall_deadline {
957                                        break;
958                                    }
959                                }
960                            }
961                        }
962                    }
963                }
964                // Off the coalescing snapshot cache, so this reuses the tick's
965                // `build_tree` rather than walking git a second time.
966                let snapshot = tree_cache.snapshot().await;
967                let watch = pr_watch_from_snapshot(&snapshot);
968                if watch.is_empty() {
969                    // No windows, or nothing on GitHub: ask nothing, and forget any
970                    // backoff so the next tree starts fresh. But **keep** `watched`
971                    // (#1389, fix 4): a VS Code restart momentarily empties the watch
972                    // mid-storm, and nulling it here would make the re-registered set
973                    // look brand-new and re-fetch. A genuinely gone tree simply has
974                    // nothing to compare against on the next non-empty tick.
975                    backoff = base;
976                    last_poll = None;
977                    moved_at = None;
978                    continue;
979                }
980                // Did the watched set **grow** (an addition, or an upstream pushed)?
981                // A pure removal is not a reason to fetch (#1389, fix 1); a local
982                // commit is not either (#1389, fix 3, `PrWatch` carries no head).
983                let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
984                // Prune verdicts for targets that vanished, so a closed worktree's
985                // badge does not linger in the cache (#1389, fix 1) — local, no
986                // network, and correct whether or not this tick goes on to fetch.
987                let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
988                pr_cache.retain_targets(&keep);
989                // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
990                // choke point, so throttling here is the one place a machine-wide cap
991                // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
992                // immediate `grew`, so no runaway in this class can drain the shared
993                // budget — structurally, not by convention.
994                let rate_limit = rate_limit_cache.get();
995                let over_budget = rate_limit.is_some_and(|s| s.over_warn());
996                let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
997                let trigger = grew && !over_budget;
998                if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
999                    // Not fetching this tick. Advance `watched` only for a pure shrink
1000                    // or a quiet identical tick — an addition/push (`grew`) must stay
1001                    // unresolved so it is still fetched once the cadence or budget
1002                    // allows, rather than being silently consumed here.
1003                    if !grew {
1004                        watched = Some(watch);
1005                    }
1006                    continue;
1007                }
1008                if grew {
1009                    // Fresh work: watch it closely and restart the escalation clock
1010                    // rather than serving out a backoff earned while it was quiet.
1011                    backoff = base;
1012                    moved_at = Some(Instant::now());
1013                }
1014                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1015                // `gh` is a blocking subprocess: never on an async worker. A join
1016                // failure (the task panicked, or the runtime is going down) folds
1017                // into the same error channel as a `gh` failure — both mean "no
1018                // badges this round", and neither deserves its own handling.
1019                let bin = gh_bin.clone();
1020                let resolved = tokio::task::spawn_blocking(move || {
1021                    crate::pr_status::resolve_with_budget(&bin, &targets)
1022                })
1023                .await
1024                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1025                // Best-effort decoration: a missing/unauthenticated `gh`, a network
1026                // blip, or a rate limit must never sink the tree. A failed poll
1027                // leaves the last good resolutions in place — badges *and* explicit
1028                // negatives, and it mints no new negatives (#1370) — rather than
1029                // blanking every row, and is not "pending", so it backs off rather
1030                // than hammers.
1031                let (pending, resolved_ok) = match resolved {
1032                    Ok((resolutions, budget)) => {
1033                        // Fold the free budget reading this poll carried into the
1034                        // shared cache (#1389, fix 8): the graphql figure stays fresh
1035                        // whenever polling is active, which lets the standalone
1036                        // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
1037                        // reveals the real per-call point price.
1038                        if let Some(b) = budget {
1039                            tracing::debug!(
1040                                "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1041                                b.cost,
1042                                b.used,
1043                                b.limit,
1044                                b.remaining
1045                            );
1046                            rate_limit_cache.observe_graphql(RateLimitResource::new(
1047                                b.used,
1048                                b.limit,
1049                                b.remaining,
1050                                b.reset,
1051                            ));
1052                        }
1053                        // Bump only on a real change, or the server's diff-and-drop
1054                        // is defeated and every window re-renders on every poll.
1055                        if pr_cache.replace(resolutions) {
1056                            registry.bump();
1057                        }
1058                        (pr_cache.any_pending(), true)
1059                    }
1060                    Err(err) => {
1061                        tracing::debug!("PR badge poll failed: {err:#}");
1062                        (false, false)
1063                    }
1064                };
1065                last_poll = Some(Instant::now());
1066                // Record what this verdict was about, so the *next* tick can tell a
1067                // genuine change from a quiet tree.
1068                watched = Some(watch);
1069                let since_moved = moved_at.map(|at| at.elapsed());
1070                backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1071                // Persist the fresh verdicts (#1389, fix 4) so the next restart
1072                // serves them and can skip its immediate re-poll. Only on a real
1073                // resolution — a failed poll must not advance the persisted poll time
1074                // past the last *good* one. Best-effort; a write failure costs at
1075                // most one extra poll after the next restart.
1076                if resolved_ok {
1077                    if let Some(path) = &pr_cache_path {
1078                        persist_pr_cache(
1079                            path,
1080                            &pr_cache,
1081                            watched.as_deref().unwrap_or(&[]),
1082                            Utc::now(),
1083                        );
1084                    }
1085                }
1086            }
1087        });
1088        *guard = Some(PollerTask { token, handle });
1089    }
1090
1091    /// Starts the background task that keeps the GitHub API rate-limit reading
1092    /// fresh (#1375).
1093    ///
1094    /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1095    /// budget as every other tool sharing the user's token; when that drains, `gh`
1096    /// rate-limits machine-wide with no warning until commands start failing. This
1097    /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1098    /// project verified) as **exempt**, spending nothing against any budget — so
1099    /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1100    /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1101    ///
1102    /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1103    /// backoff and no window-gating, because the endpoint is free and a current
1104    /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1105    /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1106    /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1107    /// bare service that never spawns `gh`.
1108    pub fn start_rate_limit_poller(&self) {
1109        // Both resolved once at spawn: process-stable env config, never re-read per
1110        // poll (the PR-poller precedent).
1111        self.start_rate_limit_poller_with(
1112            rate_limit_poll_interval(),
1113            crate::pr_status::resolve_gh_binary(),
1114        );
1115    }
1116
1117    /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1118    /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1119    /// against a stub **without mutating the process environment** (the
1120    /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1121    fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1122        if tokio::runtime::Handle::try_current().is_err() {
1123            tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1124            return;
1125        }
1126        let mut guard = self
1127            .rate_limit_poller
1128            .lock()
1129            .unwrap_or_else(PoisonError::into_inner);
1130        if guard.is_some() {
1131            return;
1132        }
1133        let token = CancellationToken::new();
1134        let loop_token = token.clone();
1135        let cache = self.rate_limit_cache.clone();
1136        let registry = self.registry.clone();
1137        let handle = tokio::spawn(async move {
1138            // Remembers the previous reading so a WARN fires only on the *rising*
1139            // edge across the threshold, not every poll while usage stays high.
1140            let mut prev: Option<RateLimitSnapshot> = None;
1141            loop {
1142                // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1143                // no window registered and no polling lease active — has nothing to
1144                // watch, so it spends no `/rate_limit` subprocess and lets the last
1145                // reading stand. The read is free against the budget, but the
1146                // wakeups are not; and while polling *is* active the graphql figure
1147                // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1148                // standalone poll is only topping up `core`/`search`.
1149                if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1150                    // Poll first, so `status` has a reading soon after a window
1151                    // appears rather than one interval later. `gh` is a blocking
1152                    // subprocess: never on an async worker. A join failure folds into
1153                    // the same channel as a `gh` failure — both mean "no fresh
1154                    // reading this round".
1155                    let bin = gh_bin.clone();
1156                    let resolved =
1157                        tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1158                            .await
1159                            .unwrap_or_else(|err| {
1160                                Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1161                            });
1162                    match resolved {
1163                        Ok(snap) => {
1164                            if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1165                                // Bound to a local (rather than inlined into the
1166                                // macro) so it is computed whenever the branch is
1167                                // taken, not only when a WARN-level subscriber is
1168                                // installed — `tracing` skips evaluating macro args
1169                                // otherwise.
1170                                let summary = snap.summary_line();
1171                                tracing::warn!(
1172                                    "GitHub API rate limit high: {summary} (querying \
1173                                     /rate_limit is free; the daemon's gh usage is not)"
1174                                );
1175                            }
1176                            // Update the cache only; deliberately no `registry.bump()`
1177                            // — the rate limit is not tree topology, and bumping would
1178                            // re-push an unchanged tree to every window. The tray
1179                            // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1180                            cache.replace(snap);
1181                            prev = Some(snap);
1182                        }
1183                        // Best-effort decoration: a missing/unauthenticated `gh` or a
1184                        // network blip leaves the last good reading in place rather
1185                        // than blanking the line, and never affects the budget (the
1186                        // read is free).
1187                        Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1188                    }
1189                }
1190                tokio::select! {
1191                    () = loop_token.cancelled() => break,
1192                    () = tokio::time::sleep(interval) => {}
1193                }
1194            }
1195        });
1196        *guard = Some(PollerTask { token, handle });
1197    }
1198
1199    /// Handles the `close` op: close a worktree's window and, for a **linked**
1200    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1201    ///
1202    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1203    ///   [`git_safety`] check returning the risks of deleting, so the extension can
1204    ///   show a modal confirm only when something would actually be lost.
1205    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1206    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1207    ///   worktree. The main working tree is refused defensively.
1208    ///
1209    /// Cross-window signalling (another window has the target open) is a
1210    /// fast-follow: this core handles the **no-window** and **self-close**
1211    /// (`requester_key == target_key`) cases, and errors clearly when another
1212    /// window owns the target so the destructive path is never taken blind.
1213    async fn close(&self, req: CloseRequest) -> Result<Value> {
1214        // Which live windows currently have the target open. The canonical-path
1215        // compare is disk I/O, so run it (with the safety check below) on a
1216        // blocking thread, never under the registry lock or on the async worker.
1217        let entries = self.registry.list();
1218        let scan_path = req.path.clone();
1219        let open_windows =
1220            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1221                .await
1222                .unwrap_or_default();
1223        let open = !open_windows.is_empty();
1224        let window_key = open_windows.first().map(|(k, _)| k.clone());
1225        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1226
1227        // Phase 1: the safety check runs only for a delete request awaiting
1228        // confirmation. A "Close Window" (remove:false) never inspects git and
1229        // has nothing to confirm, so it skips straight to execute.
1230        if req.remove && !req.confirmed {
1231            let path = req.path.clone();
1232            let git = tokio::task::spawn_blocking(move || git_safety(&path))
1233                .await
1234                .map_err(|e| anyhow!("safety check task panicked: {e}"))
1235                .and_then(|inner| inner)
1236                .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1237            // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1238            // (#1364): the target, the owning window key (if any), whether a
1239            // window has it open, and the deletability verdict — with the
1240            // blocking risk kinds that force a confirm dialog, so a later "why did
1241            // the close prompt/refuse?" is answerable from the log alone.
1242            log_safety_check(&req.path, window_key.as_deref(), &git, open);
1243            return Ok(serde_json::to_value(SafetyReport {
1244                removable: git.removable,
1245                is_main: git.is_main,
1246                open,
1247                window_key,
1248                window_folder_count,
1249                risks: git.risks,
1250                info: git.info,
1251            })
1252            .unwrap_or_else(|_| json!({})));
1253        }
1254
1255        // Phase 2: execute. Signal every owning window *other than the
1256        // requester* (which closes itself on our `ok:true` reply, avoiding the
1257        // ext-host-dies-mid-op race) and wait for each to unregister before
1258        // touching the worktree. The directive reaches a cross-window target via
1259        // its heartbeat reply — the only channel the daemon has to a window it
1260        // can reply to but never call.
1261        let others: Vec<String> = open_windows
1262            .iter()
1263            .map(|(k, _)| k.clone())
1264            .filter(|k| req.requester_key.as_deref() != Some(k))
1265            .collect();
1266        // A self-close is the requester closing a window it owns: it never rides
1267        // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1268        // (#1364) so the execute's routing decision is auditable before the wait,
1269        // even if that wait then hangs or times out.
1270        let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1271        log_executing(
1272            &req.path,
1273            req.requester_key.as_deref(),
1274            req.remove,
1275            self_close,
1276            others.len(),
1277        );
1278        for key in &others {
1279            self.registry.mark_close_pending(key);
1280        }
1281        if !others.is_empty() {
1282            if let Err(err) = await_windows_closed(
1283                &self.registry,
1284                &req.path,
1285                req.requester_key.as_deref(),
1286                CLOSE_WAIT_TIMEOUT,
1287                CLOSE_WAIT_POLL,
1288            )
1289            .await
1290            {
1291                log_close_abort(&req.path, &err);
1292                return Err(err);
1293            }
1294        }
1295
1296        if req.remove {
1297            let path = req.path.clone();
1298            // The live window set, so a working-tree-gone-but-admin-present orphan
1299            // can find its owning main repo to prune (#1403); unused on the common
1300            // path where the checkout still exists.
1301            let entries = self.registry.list();
1302            // Taken *after* the wait above, so concurrent executes still overlap
1303            // their heartbeat waits (#1359) and only the prune itself serializes.
1304            // Load-bearing placement, not incidental: hoisting this above
1305            // `await_windows_closed` would restack the waits and undo the whole
1306            // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1307            let _guard = self.prune_lock.lock().await;
1308            let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1309                .await
1310                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1311                .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1312            // The audit line + Result→reply mapping lives in a sync helper so the
1313            // destructive outcome is unit-testable off the runtime (#1364).
1314            log_and_map_removal(&req.path, removed)
1315        } else {
1316            // "Close Window" with no owning window is a no-op success; a
1317            // self-close replies and the extension closes its own window.
1318            log_window_closed(&req.path);
1319            Ok(json!({ "closed": true }))
1320        }
1321    }
1322
1323    /// Handles the `reload` op (#1417): signal each target window to reload
1324    /// itself, returning `{ requested, signalled, unknown }`.
1325    ///
1326    /// Synchronous, and deliberately so — the whole op is a set insert per key.
1327    /// It marks a directive on each *currently registered* target and returns;
1328    /// the window acts on it on its next `heartbeat`, up to the ~10s cadence
1329    /// later. Unlike [`close`](Self::close) it never waits, because a reload has
1330    /// no completion the daemon can observe (the window re-registers under the
1331    /// same key), which is why the reply says `signalled`, never `reloaded`.
1332    ///
1333    /// A key with no live window is reported in `unknown` rather than erroring:
1334    /// the batch is a sweep, and a window closing between the client rendering
1335    /// its list and sending the op is routine, not a failure. `list()` reaps
1336    /// stale entries on read, so a window that died without unregistering is
1337    /// correctly unknown here.
1338    fn reload(&self, req: ReloadRequest) -> Value {
1339        let live: HashSet<String> = self
1340            .registry
1341            .list()
1342            .into_iter()
1343            .map(|entry| entry.key)
1344            .collect();
1345
1346        let mut seen = HashSet::new();
1347        let mut signalled = 0usize;
1348        let mut unknown = Vec::new();
1349        for key in &req.target_keys {
1350            // A client repeating a key asks for one reload, not two.
1351            if !seen.insert(key.as_str()) {
1352                continue;
1353            }
1354            if live.contains(key) {
1355                self.registry.mark_reload_pending(key);
1356                signalled += 1;
1357            } else {
1358                unknown.push(key.clone());
1359            }
1360        }
1361
1362        log_reload(seen.len(), signalled, &unknown);
1363        json!({
1364            "requested": seen.len(),
1365            "signalled": signalled,
1366            "unknown": unknown,
1367        })
1368    }
1369
1370    /// Handles the `merge-queue` op (#1401): batch-enqueue the eligible worktrees'
1371    /// PRs into the GitHub merge queue. Two-phase, keyed off `confirmed`:
1372    ///
1373    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run the
1374    ///   side-effect-free eligibility evaluation ([`evaluate_batch`]) and return an
1375    ///   [`EligibilityReport`]: the enqueue-eligible worktrees and the skipped ones
1376    ///   (each with a machine `kind` + human `detail`).
1377    /// - **Phase 2** (`confirmed:true`) — **re-run** the same evaluation (never
1378    ///   trust a phase-1 result the client sent, exactly as `close` re-validates on
1379    ///   execute), then enqueue each still-eligible PR. A per-PR rejection lands in
1380    ///   `failed[]`; the batch never fails as a whole.
1381    ///
1382    /// All git and `gh` I/O runs on a blocking thread — never the async worker and
1383    /// never under the registry lock. No lock is taken: each enqueue mutates a
1384    /// distinct remote PR, not a shared local resource.
1385    async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1386        // Resolved once here (the env read is process-stable), then handed to the
1387        // seam below — the `open_prs`/`open_prs_with` "bin as a param" pattern, so a
1388        // test drives the eligibility + enqueue paths against a fake `gh` without
1389        // touching the environment (#1030).
1390        self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1391            .await
1392    }
1393
1394    /// [`merge_queue`](Self::merge_queue) with an explicit `gh` binary, so a test
1395    /// exercises the phase-1 network resolve and the phase-2 enqueue against a stub.
1396    async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1397        // Report-only unless explicitly confirmed; an explicit `check` request
1398        // always reports and never enqueues.
1399        let report_only = req.check || !req.confirmed;
1400
1401        let eval_bin = bin.clone();
1402        let eval_paths = req.paths.clone();
1403        let (eligible, skipped) =
1404            tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1405                .await
1406                .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1407                .and_then(|inner| inner)?;
1408
1409        if report_only {
1410            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1411            log_merge_check(&req, eligible.len(), skipped.len());
1412            let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1413            return Ok(
1414                serde_json::to_value(EligibilityReport { eligible, skipped })
1415                    .unwrap_or_else(|_| json!({})),
1416            );
1417        }
1418
1419        // Phase 2: enqueue the freshly re-validated eligible set, sequentially.
1420        let enqueue_bin = bin.clone();
1421        let (queued, failed) =
1422            tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1423                .await
1424                .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1425        log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1426        Ok(serde_json::to_value(EnqueueResult {
1427            queued,
1428            skipped,
1429            failed,
1430        })
1431        .unwrap_or_else(|_| json!({})))
1432    }
1433
1434    /// Handles the `rebase` op (#1415): batch-rebase the selected worktrees onto
1435    /// their repository's remote default branch, fetching it **once per
1436    /// repository**. Two-phase, keyed off `confirmed`, exactly like `merge-queue`:
1437    ///
1438    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1439    ///   [`worktree_rebase::plan`], which fetches once per repo and classifies
1440    ///   every selected worktree. This *is* the "only rebase if it makes sense
1441    ///   from the current git state" gate: the classifier skips a detached HEAD, a
1442    ///   dirty tree, an operation already in progress, a non-worktree path, an
1443    ///   unresolvable onto ref, and anything already up to date — the main working
1444    ///   tree is a valid target like any other (ADR-0060). Side-effect-free apart
1445    ///   from the fetch, which only advances a remote-tracking ref.
1446    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch** (never trust a
1447    ///   phase-1 result the client sent back, as `close` and `merge-queue` do),
1448    ///   then execute. A worktree that went dirty between the phases is skipped
1449    ///   rather than rebased.
1450    ///
1451    /// **Why the daemon may do this at all** (ADR-0059): ADR-0055 confined the
1452    /// rebase to the CLI on the premise that the daemon could not authenticate a
1453    /// fetch. It can — launchd exports `SSH_AUTH_SOCK` into the per-user session,
1454    /// so the daemon inherits the user's `ssh-agent`. The real gap was the minimal
1455    /// `PATH`, closed by [`crate::git::resolve_git_binary`].
1456    ///
1457    /// All git I/O runs on a blocking thread, never the async worker and never
1458    /// under the registry lock.
1459    async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1460        // Resolved once here (the probe is process-stable), then handed to the
1461        // seam below — the `merge_queue_with` "bin as a param" pattern, so a test
1462        // drives both phases against a stub without touching the environment.
1463        self.rebase_with(req, crate::git::resolve_git_binary())
1464            .await
1465    }
1466
1467    /// [`rebase`](Self::rebase) with an explicit `git` binary, so a test exercises
1468    /// the plan and execute paths against a stub.
1469    async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1470        if req.paths.is_empty() {
1471            bail!("`rebase` requires at least one path");
1472        }
1473        // Report-only unless explicitly confirmed; an explicit `check` request
1474        // always reports and never rebases.
1475        let report_only = req.check || !req.confirmed;
1476        let opts = req.options(git_bin);
1477        let selection = Selection::Paths(req.paths.clone());
1478
1479        if report_only {
1480            // Phase 1: fetch once per repo and classify, rebase nothing. No lock —
1481            // it mutates no worktree, and a plan is allowed to race an execute.
1482            let plan = plan_rebase(&selection, &opts).await?;
1483            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1484            log_rebase_check(&req, &plan);
1485            return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1486        }
1487
1488        // Phase 2: re-plan and execute, serialized against other executes —
1489        // linked worktrees share one object database (see `rebase_lock`).
1490        //
1491        // The lock is taken **before** the re-plan, not merely around the execute,
1492        // and that ordering is load-bearing. A plan taken outside it can be
1493        // invalidated by a concurrent execute before this one gets its turn — and
1494        // acting on a stale plan is not benign: if the other run left a worktree
1495        // mid-rebase, a stale `WouldRebase` here would run `git rebase` against a
1496        // repository that is already mid-rebase and (without `keep_conflicts`)
1497        // `--abort` it, destroying exactly the conflict resolution the other run
1498        // was preserving. Planning under the lock means the classifier sees that
1499        // worktree's real state and skips it as `operation-in-progress`.
1500        let _guard = self.rebase_lock.lock().await;
1501        let plan = plan_rebase(&selection, &opts).await?;
1502        // The worktrees actually about to be rewritten, canonicalized here (disk
1503        // I/O belongs in the adapter, not the registry) so they match the tree
1504        // snapshot's own keys.
1505        let pending: Vec<PathBuf> = plan
1506            .worktrees
1507            .iter()
1508            .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1509            .map(|w| canonical(&w.path))
1510            .collect();
1511        self.registry.mark_rebasing(&pending);
1512        let fetches = plan.fetches.clone();
1513        let exec_opts = opts.clone();
1514        let outcomes =
1515            tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1516        // Cleared on **every** exit, including a panicked task, so a failed rebase
1517        // can never leave a permanent spinner on a tree row.
1518        self.registry.clear_rebasing(&pending);
1519        let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1520
1521        log_rebase_execute(&req, &outcomes);
1522        Ok(rebase_reply(&fetches, &outcomes))
1523    }
1524
1525    /// Handles the `push` op (#1443): publish the selected worktrees' branches to
1526    /// their upstreams, force-pushing **with a lease** where a rebase rewrote
1527    /// history. The complement of [`rebase`](Self::rebase), and two-phase in
1528    /// exactly the same way:
1529    ///
1530    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1531    ///   [`worktree_push::plan`], which classifies every selected worktree against
1532    ///   its upstream. This *is* the "does this make sense?" gate: it skips a
1533    ///   detached HEAD (which also covers a worktree mid-rebase), a non-worktree
1534    ///   path, a branch with nowhere to publish, and a force-push of the
1535    ///   repository's remote default branch — while a dirty tree is deliberately
1536    ///   **not** a skip, since a push publishes commits rather than the working
1537    ///   tree. Unlike `rebase`'s phase 1 this is not merely side-effect-*light*: it
1538    ///   contacts no remote at all (ADR-0061).
1539    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch**, then execute. A
1540    ///   phase-1 result the client sends back is never trusted, as `close`,
1541    ///   `merge-queue` and `rebase` all re-validate. A branch that moved between
1542    ///   the phases is re-classified, not pushed on stale information.
1543    ///
1544    /// **Why the daemon may do this at all** (ADR-0061): this is the first op to
1545    /// write to a remote using the user's ambient *git* credentials — `merge-queue`
1546    /// mutates the remote through `gh` (ADR-0056) and `rebase` only fetches
1547    /// (ADR-0059). It stays same-user-bounded behind the `0600` socket, publishes
1548    /// only branches the client names, and can never overwrite work it has not
1549    /// seen, because the lease is enforced by `git` itself.
1550    ///
1551    /// All git I/O runs on a blocking thread, never the async worker and never
1552    /// under the registry lock.
1553    async fn push(&self, req: PushRequest) -> Result<Value> {
1554        // Resolved once here (the probe is process-stable), then handed to the
1555        // seam below — the `rebase_with` "bin as a param" pattern.
1556        self.push_with(req, crate::git::resolve_git_binary()).await
1557    }
1558
1559    /// [`push`](Self::push) with an explicit `git` binary, so a test exercises the
1560    /// plan and execute paths against a stub.
1561    async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1562        if req.paths.is_empty() {
1563            bail!("`push` requires at least one path");
1564        }
1565        // Report-only unless explicitly confirmed; an explicit `check` request
1566        // always reports and never pushes.
1567        let report_only = req.check || !req.confirmed;
1568        let selection = Selection::Paths(req.paths.clone());
1569
1570        if report_only {
1571            // Phase 1: classify only. No lock and no network — planning reads the
1572            // local remote-tracking refs, which is exactly what the lease is
1573            // checked against.
1574            let plan = plan_push(&selection).await?;
1575            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1576            log_push_check(&req, &plan);
1577            return Ok(push_reply(&plan.worktrees));
1578        }
1579
1580        // Phase 2: re-plan and execute, serialized against other executes — a push
1581        // writes into the ref store linked worktrees share (see `push_lock`). The
1582        // lock is taken **before** the re-plan for the same reason `rebase` takes
1583        // its own that way: a plan plus a lease are only meaningful together, and a
1584        // plan taken outside the lock can be invalidated before its turn comes.
1585        let _guard = self.push_lock.lock().await;
1586        let plan = plan_push(&selection).await?;
1587        // The worktrees actually about to be published, canonicalized here (disk
1588        // I/O belongs in the adapter, not the registry) so they match the tree
1589        // snapshot's own keys.
1590        let pending: Vec<PathBuf> = plan
1591            .worktrees
1592            .iter()
1593            .filter(|w| w.result.is_pending())
1594            .map(|w| canonical(&w.path))
1595            .collect();
1596        self.registry.mark_pushing(&pending);
1597        let opts = worktree_push::PushOptions {
1598            git_bin: Some(git_bin),
1599        };
1600        let outcomes =
1601            tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1602        // Cleared on **every** exit, including a panicked task. A push writes no
1603        // on-disk state, so this set is the *whole* cue — a mark left behind would
1604        // be a permanent spinner nothing else could correct.
1605        self.registry.clear_pushing(&pending);
1606        let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1607
1608        log_push_execute(&req, &outcomes);
1609        Ok(push_reply(&outcomes))
1610    }
1611
1612    /// Handles the `reposition` op (#1407): move and resize each target worktree's
1613    /// **already-open** VS Code window to match the invoking window's geometry.
1614    ///
1615    /// The invoking window is the reference: it supplies the frame and is never
1616    /// itself moved. A target with no open window, no resolvable OS window, or an
1617    /// ambiguous name is reported rather than guessed at. Z-order is untouched —
1618    /// see [`geometry::ax`] for why the Accessibility API gives that for free, and
1619    /// why this must **not** reuse [`focus_window`], which deliberately raises.
1620    ///
1621    /// `check: true` is a dry run: everything resolves exactly as it would for a
1622    /// real run, but nothing is written. That is the whole diagnostic surface for
1623    /// title matching (`worktrees reposition --dry-run`).
1624    ///
1625    /// Not two-phase like `close`/`merge-queue`: nothing durable is created,
1626    /// modified, or destroyed, so a confirmation on a routine layout command would
1627    /// cost more than it protects. Reversibility is provided instead, by
1628    /// [`reposition_undo`](Self::reposition_undo).
1629    async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1630        self.reposition_with(req, geometry::ax::AxBackend::new)
1631            .await
1632    }
1633
1634    /// [`reposition`](Self::reposition) with the platform backend injected as a
1635    /// **factory**, so a test drives the whole op — key resolution, the undo
1636    /// store, the reply shape — against a fake with no `unsafe` and no windows.
1637    ///
1638    /// A factory rather than the backend itself because the real backend holds
1639    /// CoreFoundation references and so is neither `Send` nor `Sync`: it has to be
1640    /// built *inside* the blocking closure. The `merge_queue_with` "seam as a
1641    /// parameter" pattern, one level of indirection over.
1642    async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1643    where
1644        B: geometry::WindowBackend,
1645        F: FnOnce() -> B + Send + 'static,
1646    {
1647        if req.reference_key.trim().is_empty() {
1648            bail!("`reposition` requires a non-empty `reference_key`");
1649        }
1650        // Resolve keys against the registry *here*, so all AX work below deals in
1651        // plain data and the registry lock is never held across the blocking join.
1652        let entries = self.registry.list();
1653        let reference = registered_window(&entries, &req.reference_key);
1654        if !reference.live {
1655            // Unlike a target, an unresolvable *reference* is a hard error: there
1656            // is no geometry to copy, so the request cannot mean anything.
1657            bail!(
1658                "no open window with key {} (it may have closed)",
1659                req.reference_key
1660            );
1661        }
1662        // A target key with no live window is a reportable per-target outcome, not
1663        // a failure of the batch — the tree row may simply be a tick stale.
1664        let targets: Vec<geometry::RegisteredWindow> = req
1665            .target_keys
1666            .iter()
1667            .map(|key| registered_window(&entries, key))
1668            .collect();
1669
1670        let check = req.check;
1671        let mut report = tokio::task::spawn_blocking(move || {
1672            // The backend lives for exactly one op, so its enumeration cache can
1673            // never serve a window that has since moved or closed.
1674            let backend = make_backend();
1675            geometry::reposition(&backend, &reference, &targets, check)
1676        })
1677        .await
1678        .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1679
1680        // Taken out of the report rather than cloned: nothing downstream reads it,
1681        // and the store is its only owner. A dry run leaves the previous batch's
1682        // record intact — it changed nothing, so there is neither something new to
1683        // undo nor something stale to discard.
1684        let undo = std::mem::take(&mut report.undo);
1685        let undoable = !check && !undo.is_empty();
1686        if undoable {
1687            *self
1688                .reposition_undo
1689                .lock()
1690                .unwrap_or_else(PoisonError::into_inner) = undo;
1691        }
1692        log_reposition(&req, &report);
1693        Ok(reposition_reply(&report, undoable))
1694    }
1695
1696    /// Handles the `reposition-undo` op (#1407): put the windows the last
1697    /// `reposition` moved back where they were.
1698    ///
1699    /// Consumes the stored batch, so an undo cannot be replayed onto windows the
1700    /// user has since arranged by hand. Every window is re-resolved from scratch —
1701    /// one that has closed, been renamed, or gone fullscreen in the meantime is
1702    /// reported, not forced.
1703    async fn reposition_undo(&self) -> Result<Value> {
1704        self.reposition_undo_with(geometry::ax::AxBackend::new)
1705            .await
1706    }
1707
1708    /// [`reposition_undo`](Self::reposition_undo) with the backend factory
1709    /// injected, for the same reasons as
1710    /// [`reposition_with`](Self::reposition_with).
1711    async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1712    where
1713        B: geometry::WindowBackend,
1714        F: FnOnce() -> B + Send + 'static,
1715    {
1716        let stored = std::mem::take(
1717            &mut *self
1718                .reposition_undo
1719                .lock()
1720                .unwrap_or_else(PoisonError::into_inner),
1721        );
1722        if stored.is_empty() {
1723            return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1724        }
1725        let entries = self.registry.list();
1726        let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1727            .into_iter()
1728            .map(|(key, frame)| (registered_window(&entries, &key), frame))
1729            .collect();
1730
1731        let report = tokio::task::spawn_blocking(move || {
1732            let backend = make_backend();
1733            geometry::restore(&backend, &restore)
1734        })
1735        .await
1736        .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1737        log_reposition_undo(&report);
1738        Ok(reposition_reply(&report, false))
1739    }
1740}
1741
1742impl Default for WorktreesService {
1743    fn default() -> Self {
1744        Self::new()
1745    }
1746}
1747
1748#[async_trait]
1749impl DaemonService for WorktreesService {
1750    fn name(&self) -> &'static str {
1751        SERVICE_NAME
1752    }
1753
1754    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1755        match op {
1756            "register" => {
1757                let req: RegisterRequest =
1758                    serde_json::from_value(payload).context("invalid `register` payload")?;
1759                if req.key.trim().is_empty() {
1760                    bail!("`register` requires a non-empty `key`");
1761                }
1762                self.registry.register(req);
1763                Ok(json!({ "ok": true }))
1764            }
1765            "heartbeat" => {
1766                let key = require_str(&payload, "key", "heartbeat")?;
1767                let known = self.registry.heartbeat(key);
1768                // A pending close directive (#1277) rides the reply as an
1769                // additive `close` field, taken-and-cleared here so it fires
1770                // exactly once. Omitted when false to keep older windows — which
1771                // read only `known` — byte-identical on the wire.
1772                let mut reply = json!({ "known": known });
1773                if self.registry.take_close_pending(key) {
1774                    reply["close"] = Value::Bool(true);
1775                }
1776                // A pending reload directive (#1417) rides the same reply, on
1777                // the same terms. Deliberately an independent `if`, not an
1778                // `else`: each field then means exactly "this directive was
1779                // pending", and each is taken exactly once regardless of the
1780                // other. The companion resolves a both-set collision by
1781                // checking `close` first, since closing subsumes reloading.
1782                if self.registry.take_reload_pending(key) {
1783                    reply["reload"] = Value::Bool(true);
1784                }
1785                Ok(reply)
1786            }
1787            "unregister" => {
1788                let key = require_str(&payload, "key", "unregister")?;
1789                Ok(json!({ "removed": self.registry.unregister(key) }))
1790            }
1791            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1792            "tree" => {
1793                // The same `{ repos, show_closed }` snapshot the `subscribe`
1794                // stream pushes, so a one-shot `tree` fetch and the live stream
1795                // agree byte-for-byte (the git enumeration runs off-lock on a
1796                // blocking thread inside the helper). Computed fresh here — the
1797                // `tree` op is a rare manual refresh, deliberately bypassing the
1798                // stream's coalescing cache so it never returns a stale view
1799                // (#1303).
1800                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1801            }
1802            "ahead-behind" => {
1803                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1804                // snapshot no longer carries ahead/behind — the dominant
1805                // per-worktree cost when computed eagerly every tick — so a client
1806                // (the extension on expand, `worktrees tree`) asks for it here only
1807                // for the worktrees it is about to show. Batched by path, one op per
1808                // repo expand; the git walks run on a blocking thread.
1809                let paths = payload
1810                    .get("paths")
1811                    .and_then(Value::as_array)
1812                    .map(|arr| {
1813                        arr.iter()
1814                            .filter_map(Value::as_str)
1815                            .map(PathBuf::from)
1816                            .collect::<Vec<_>>()
1817                    })
1818                    .unwrap_or_default();
1819                Ok(json!({ "results": ahead_behind_results(paths).await }))
1820            }
1821            "set-show-closed" => {
1822                // The daemon-backed show/hide-closed toggle (#1301). Setting it
1823                // bumps the change-notify, so every subscribed window re-pushes a
1824                // snapshot carrying the new `show_closed` — reliable cross-window
1825                // sync `context.globalState` could not do.
1826                let show_closed = payload
1827                    .get("show_closed")
1828                    .and_then(Value::as_bool)
1829                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1830                self.registry.set_show_closed(show_closed);
1831                Ok(json!({ "ok": true }))
1832            }
1833            "set-polling" => {
1834                // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1835                // poller resolving its badges (default is off — zero `gh`);
1836                // disabling stops it and drops its badges. `set_polling` bumps the
1837                // change-notify on a real change, so every subscribed window
1838                // re-pushes a `tree` snapshot carrying the new per-repo
1839                // `polling_enabled` — the reliable cross-window sync the
1840                // `set-show-closed` precedent relies on. A changed value is
1841                // persisted so it survives a daemon restart.
1842                let owner = require_str(&payload, "owner", "set-polling")?;
1843                let name = require_str(&payload, "name", "set-polling")?;
1844                let enabled = payload
1845                    .get("enabled")
1846                    .and_then(Value::as_bool)
1847                    .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1848                if owner.trim().is_empty() || name.trim().is_empty() {
1849                    bail!("`set-polling` requires a non-empty `owner` and `name`");
1850                }
1851                if self.registry.set_polling(owner, name, enabled) {
1852                    self.persist_polling_prefs();
1853                }
1854                Ok(json!({ "ok": true }))
1855            }
1856            "open-prs" => {
1857                // Serve "Open Pull Request…" (and the extension's transient badge
1858                // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1859                // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1860                // call instead of each shelling its own (the per-window burn
1861                // #1370/#1389 target). Repo-wide; the client filters by branch for a
1862                // worktree-scoped lookup, and answers a badged branch straight from
1863                // the snapshot (zero `gh`) without ever reaching here.
1864                let owner = require_str(&payload, "owner", "open-prs")?;
1865                let name = require_str(&payload, "name", "open-prs")?;
1866                if owner.trim().is_empty() || name.trim().is_empty() {
1867                    bail!("`open-prs` requires a non-empty `owner` and `name`");
1868                }
1869                Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1870            }
1871            "open" => {
1872                // Focus (or open — VS Code reuses an already-open window) an
1873                // arbitrary worktree folder supplied by a socket client, reusing
1874                // the tray's launcher path: `focus_window` resolves the launcher
1875                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1876                // the absolute-existing-directory guard (which also blocks a
1877                // `-`-leading path being parsed by `code` as a flag). This is the
1878                // one op a socket *writer* can use to spawn `code`; see the
1879                // ADR-0040 threat model (#1266).
1880                let path = require_str(&payload, "path", "open")?;
1881                focus_window(Path::new(path))?;
1882                Ok(json!({ "ok": true }))
1883            }
1884            "close" => {
1885                // Close a worktree's window and (for a linked worktree)
1886                // **delete** it. Destructive, so all git logic stays in the
1887                // daemon (git2, never a shell) and the main working tree is
1888                // refused defensively — the UI gating is not the only guard.
1889                // See ADR-0049 and docs/worktrees-service.md.
1890                let req: CloseRequest =
1891                    serde_json::from_value(payload).context("invalid `close` payload")?;
1892                self.close(req).await
1893            }
1894            "reload" => {
1895                // Signal each target window to reload itself (#1417). Addressed
1896                // by window key like `reposition`, not by path like `close`: a
1897                // reload acts on a *window*, and one tree row is one window,
1898                // whereas a path can be open in several. Nothing here is
1899                // destructive and nothing waits — the directive is marked and
1900                // the reply says only what was *signalled*. See
1901                // docs/worktrees-service.md.
1902                let req: ReloadRequest =
1903                    serde_json::from_value(payload).context("invalid `reload` payload")?;
1904                Ok(self.reload(req))
1905            }
1906            "merge-queue" => {
1907                // Batch-enqueue eligible worktrees' PRs into the GitHub merge
1908                // queue (#1401). Two-phase like `close` (side-effect-free
1909                // eligibility check → confirmed enqueue) and daemon-re-validated,
1910                // but a single batched op over `paths`. All git/`gh` work runs on
1911                // a blocking thread; enqueue authenticates through the user's own
1912                // `gh`. See ADR-0056 and docs/worktrees-service.md.
1913                let req: MergeQueueRequest =
1914                    serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1915                self.merge_queue(req).await
1916            }
1917            "rebase" => {
1918                // Batch-rebase the selected worktrees onto their repo's remote
1919                // default branch, fetching once per repo (#1415). Two-phase like
1920                // `close`/`merge-queue` (side-effect-free plan → confirmed
1921                // execute) and daemon-re-validated. The fetch authenticates
1922                // through the user's own `ssh-agent`, which launchd exports into
1923                // the daemon's environment — the premise ADR-0055 got wrong. All
1924                // git work runs on a blocking thread. See ADR-0059, ADR-0055 and
1925                // docs/worktrees-service.md.
1926                let req: RebaseRequest =
1927                    serde_json::from_value(payload).context("invalid `rebase` payload")?;
1928                self.rebase(req).await
1929            }
1930            "push" => {
1931                // Publish the selected worktrees' branches to their upstreams,
1932                // force-pushing **with a lease** where a rebase rewrote history
1933                // (#1443). Two-phase like `rebase` (side-effect-free plan →
1934                // confirmed execute) and daemon-re-validated, but its plan phase
1935                // contacts no remote at all. The push authenticates through the
1936                // user's own ambient git credentials — the first op to *write* to
1937                // a remote that way. There is no force escape hatch and no remote
1938                // override: a refused lease is the feature working. See ADR-0061
1939                // and docs/worktrees-service.md.
1940                let req: PushRequest =
1941                    serde_json::from_value(payload).context("invalid `push` payload")?;
1942                self.push(req).await
1943            }
1944            "reposition" => {
1945                // Move each target's already-open VS Code window onto the invoking
1946                // window's geometry (#1407). The one op that reaches outside the
1947                // process to control another application's windows, so all of its
1948                // OS interaction is confined to the `geometry::ax` module behind
1949                // the macOS Accessibility permission — geometry only, never a
1950                // raise, so Z-order is untouched. All AX I/O runs on a blocking
1951                // thread. See ADR-0058 and docs/worktrees-service.md.
1952                let req: RepositionRequest =
1953                    serde_json::from_value(payload).context("invalid `reposition` payload")?;
1954                self.reposition(req).await
1955            }
1956            "reposition-undo" => {
1957                // Put the windows the last `reposition` moved back where they were
1958                // (#1407). Payload-free: the daemon holds the one-level undo
1959                // record, so the client cannot ask to restore arbitrary geometry.
1960                self.reposition_undo().await
1961            }
1962            other => bail!("unknown worktrees op: {other}"),
1963        }
1964    }
1965
1966    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1967        // The single streaming op: a live push of the repo/worktree `tree`
1968        // snapshot. Every other op falls through to the request→reply `handle`.
1969        if op != "subscribe" {
1970            return None;
1971        }
1972        Some(Box::new(WorktreesStream {
1973            // Every stream reads through the one shared cache, so N windows
1974            // sampling the same tick build the tree once, not N times (#1303).
1975            cache: self.tree_cache.clone(),
1976            // Capture the change source *now* — before the server takes its
1977            // initial snapshot — so a change racing that snapshot still wakes us.
1978            changes: self.registry.subscribe_changes(),
1979        }))
1980    }
1981
1982    fn menu(&self) -> MenuSnapshot {
1983        // Serve the snapshot the background task maintains off the main thread;
1984        // fall back to a one-off inline compute only before the first refresh
1985        // lands (or with no runtime — the unit tests). Never blocks on git here
1986        // in the daemon, honouring the trait's "cheap, must not block" contract.
1987        let cached = self
1988            .menu_cache
1989            .lock()
1990            .unwrap_or_else(PoisonError::into_inner)
1991            .clone();
1992        let items = cached.unwrap_or_else(|| {
1993            menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1994        });
1995        MenuSnapshot {
1996            title: SUBMENU_TITLE.to_string(),
1997            items,
1998        }
1999    }
2000
2001    async fn menu_action(&self, action_id: &str) -> Result<()> {
2002        if let Some(key) = action_id.strip_prefix("focus:") {
2003            // The registry resolves the folder under its own lock and clones it
2004            // out, so the mutex is never held across the process launch.
2005            let folder = self
2006                .registry
2007                .first_folder(key)
2008                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2009            focus_window(&folder)?;
2010            return Ok(());
2011        }
2012        bail!("unknown worktrees menu action: {action_id}")
2013    }
2014
2015    async fn status(&self) -> ServiceStatus {
2016        let entries = self.registry.list();
2017        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2018        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2019        let windows = enriched_windows(entries).await;
2020        ServiceStatus {
2021            name: SERVICE_NAME.to_string(),
2022            healthy: true,
2023            summary,
2024            detail: json!({ "windows": windows }),
2025        }
2026    }
2027
2028    async fn shutdown(&self) {
2029        // Stop the background menu-refresh task; the registry itself is in-memory
2030        // with nothing to drain or persist. Take the task out from under the lock
2031        // first so the `std::Mutex` is never held across the `.await`.
2032        let task = self
2033            .refresh
2034            .lock()
2035            .unwrap_or_else(PoisonError::into_inner)
2036            .take();
2037        if let Some(task) = task {
2038            task.token.cancel();
2039            let _ = task.handle.await;
2040        }
2041        // Same discipline for the PR badge poller (#1337): take it out from under
2042        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
2043        let poller = self
2044            .poller
2045            .lock()
2046            .unwrap_or_else(PoisonError::into_inner)
2047            .take();
2048        if let Some(poller) = poller {
2049            poller.token.cancel();
2050            let _ = poller.handle.await;
2051        }
2052        // And the GitHub rate-limit poller (#1375), same discipline.
2053        let rate_limit_poller = self
2054            .rate_limit_poller
2055            .lock()
2056            .unwrap_or_else(PoisonError::into_inner)
2057            .take();
2058        if let Some(poller) = rate_limit_poller {
2059            poller.token.cancel();
2060            let _ = poller.handle.await;
2061        }
2062    }
2063}
2064
2065/// Extracts a required string `field` from an op payload, erroring with the op
2066/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
2067/// (`key`) and `open` (`path`).
2068fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2069    payload
2070        .get(field)
2071        .and_then(Value::as_str)
2072        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2073}
2074
2075/// The live git state of a worktree folder: the checked-out branch and how far
2076/// it has diverged from its upstream. Computed on read from the on-disk repo
2077/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
2078/// snapshot taken at registration.
2079///
2080/// Every field is optional and degrades independently: a folder that is not a
2081/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
2082/// listed — just without the fields it cannot supply. The `skip_serializing_if`
2083/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
2084/// omitting each absent field on the wire.
2085#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2086struct GitStatus {
2087    /// The checked-out branch, or `None` when detached or not in a repo.
2088    #[serde(skip_serializing_if = "Option::is_none")]
2089    branch: Option<String>,
2090    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
2091    /// even on a detached HEAD, which has a commit but no branch. Rides the
2092    /// streamed snapshot so a new commit is a real delta the server's diff cannot
2093    /// drop — without it, a push serialises byte-identically and no client
2094    /// re-renders (#1337).
2095    #[serde(skip_serializing_if = "Option::is_none")]
2096    head_sha: Option<String>,
2097    /// The commit the branch's configured upstream ref points at, or `None`
2098    /// without an upstream (or when detached, unborn, or not in a repo). Rides
2099    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
2100    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
2101    /// field — `head_sha` included — byte-identical, so without this the frame
2102    /// serialised the same, the server's diff dropped it, and the lazily-fetched
2103    /// ahead/behind was never re-asked (#1344).
2104    #[serde(skip_serializing_if = "Option::is_none")]
2105    upstream_sha: Option<String>,
2106    /// Commits the branch is ahead of its upstream (`None` without an upstream).
2107    #[serde(skip_serializing_if = "Option::is_none")]
2108    ahead: Option<usize>,
2109    /// Commits the branch is behind its upstream (`None` without an upstream).
2110    #[serde(skip_serializing_if = "Option::is_none")]
2111    behind: Option<usize>,
2112    /// The main repository's directory name — the parent repo for a linked
2113    /// worktree, the checkout's own directory otherwise. Derived from git's
2114    /// common dir so a worktree names the repo it belongs to rather than its
2115    /// worktree-folder basename. `None` when not in a repo.
2116    #[serde(skip_serializing_if = "Option::is_none")]
2117    main_repo: Option<String>,
2118    /// Whether the enriched folder is a **linked** git worktree rather than the
2119    /// repository's main working tree. Omitted (false) for a normal checkout.
2120    #[serde(skip_serializing_if = "is_false")]
2121    is_worktree: bool,
2122    /// The multi-step git operation the worktree is in the middle of (#1415):
2123    /// `rebase`, `rebase-interactive`, `merge`, `cherry-pick`, `revert`, `bisect`
2124    /// or `apply-mailbox`. `None` for a clean worktree (the overwhelming case), so
2125    /// the field is omitted on the wire and an older client is byte-identical.
2126    ///
2127    /// This is the **durable** half of the tree's rebase cue: read fresh off disk
2128    /// on every snapshot, it survives a daemon restart and keeps showing a
2129    /// conflict the `rebase` op left in place until the user resolves it. The
2130    /// transient half — "the daemon is rebasing this right now" — comes from the
2131    /// registry's in-memory set instead (see [`TreeWorktree::rebasing`]).
2132    ///
2133    /// Cheap enough for the every-worktree-every-tick snapshot (#1306's bar):
2134    /// `Repository::state()` stats a handful of paths under `.git`, which is
2135    /// nothing beside the `Repository::discover` this function already does — and
2136    /// unlike `graph_ahead_behind` it is neither a revwalk nor an object lookup.
2137    #[serde(skip_serializing_if = "Option::is_none")]
2138    operation: Option<String>,
2139}
2140
2141/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
2142/// field is dropped on the wire unless set — keeping older clients byte-identical
2143/// (the protocol's forward-compatibility convention).
2144#[allow(clippy::trivially_copy_pass_by_ref)]
2145fn is_false(b: &bool) -> bool {
2146    !*b
2147}
2148
2149/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
2150/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
2151/// daemon restart within the lease window keep the *remaining* time rather than
2152/// resetting the 15-minute clock; an already-expired entry is dropped on load.
2153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2154struct PollingLease {
2155    repo: String,
2156    expires_at: DateTime<Utc>,
2157}
2158
2159/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
2160/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
2161/// absence means not-polled (the default-off model) — so the file stays small (a
2162/// handful of active repos out of many open). `#[serde(default)]` so an
2163/// empty/older file decodes to "nothing enabled".
2164#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2165struct PollingPrefs {
2166    #[serde(default)]
2167    enabled: Vec<PollingLease>,
2168}
2169
2170/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
2171/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
2172/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
2173fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2174    if let Some(parent) = path.parent() {
2175        crate::daemon::paths::ensure_dir_0700(parent)?;
2176    }
2177    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2178    crate::daemon::paths::write_file_0600(path, &json)
2179}
2180
2181// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
2182
2183/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
2184///
2185/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
2186/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
2187/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
2188/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
2189/// compared against the worktree's current HEAD via
2190/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
2191/// `head_oid` would render every restored badge stale — so it carries the field
2192/// explicitly under a stable snake_case name.
2193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2194struct PersistedBadge {
2195    number: u64,
2196    is_draft: bool,
2197    checks: PrCheckState,
2198    url: String,
2199    head_oid: String,
2200}
2201
2202/// A persisted resolution — the disk twin of
2203/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
2204/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
2205/// `{ "Pr": { … } }`.
2206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2207enum PersistedResolution {
2208    Pr(PersistedBadge),
2209    NoPr,
2210}
2211
2212/// One persisted cache entry: which target, and the verdict last resolved for it.
2213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2214struct PersistedEntry {
2215    target: PrTarget,
2216    resolution: PersistedResolution,
2217}
2218
2219/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
2220/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
2221/// immediate re-poll" from "a target was added or a branch pushed while we were
2222/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
2223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2224struct PersistedWatch {
2225    target: PrTarget,
2226    #[serde(default, skip_serializing_if = "Option::is_none")]
2227    upstream_sha: Option<String>,
2228}
2229
2230/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
2231/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
2232/// so an empty/older/partial file decodes to "nothing restored" rather than
2233/// failing the load — best-effort, exactly like [`PollingPrefs`].
2234#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2235struct PrCachePrefs {
2236    #[serde(default)]
2237    entries: Vec<PersistedEntry>,
2238    #[serde(default)]
2239    watched: Vec<PersistedWatch>,
2240    #[serde(default, skip_serializing_if = "Option::is_none")]
2241    polled_at: Option<DateTime<Utc>>,
2242}
2243
2244impl PersistedResolution {
2245    /// The disk form of a live resolution.
2246    fn from_resolution(r: &PrResolution) -> Self {
2247        match r {
2248            PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2249                number: b.number,
2250                is_draft: b.is_draft,
2251                checks: b.checks,
2252                url: b.url.clone(),
2253                head_oid: b.head_oid.clone(),
2254            }),
2255            PrResolution::NoPr => Self::NoPr,
2256        }
2257    }
2258
2259    /// The live form of a restored resolution.
2260    fn into_resolution(self) -> PrResolution {
2261        match self {
2262            Self::Pr(b) => PrResolution::Pr(PrBadge {
2263                number: b.number,
2264                is_draft: b.is_draft,
2265                checks: b.checks,
2266                url: b.url,
2267                head_oid: b.head_oid,
2268            }),
2269            Self::NoPr => PrResolution::NoPr,
2270        }
2271    }
2272}
2273
2274/// Assembles the on-disk cache from the live cache entries, the watch they were
2275/// resolved for, and the poll time.
2276fn pr_cache_prefs_from(
2277    entries: Vec<(PrTarget, PrResolution)>,
2278    watched: &[PrWatch],
2279    polled_at: DateTime<Utc>,
2280) -> PrCachePrefs {
2281    let mut entries: Vec<PersistedEntry> = entries
2282        .into_iter()
2283        .map(|(target, resolution)| PersistedEntry {
2284            target,
2285            resolution: PersistedResolution::from_resolution(&resolution),
2286        })
2287        .collect();
2288    // Stable on-disk order so the file does not churn on rewrite from `HashMap`
2289    // iteration order alone (the same reason `PollingPrefs` sorts).
2290    entries.sort_by(|a, b| a.target.cmp(&b.target));
2291    let mut watched: Vec<PersistedWatch> = watched
2292        .iter()
2293        .map(|w| PersistedWatch {
2294            target: w.target.clone(),
2295            upstream_sha: w.upstream_sha.clone(),
2296        })
2297        .collect();
2298    watched.sort_by(|a, b| a.target.cmp(&b.target));
2299    PrCachePrefs {
2300        entries,
2301        watched,
2302        polled_at: Some(polled_at),
2303    }
2304}
2305
2306/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
2307/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
2308fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2309    if let Some(parent) = path.parent() {
2310        crate::daemon::paths::ensure_dir_0700(parent)?;
2311    }
2312    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2313    crate::daemon::paths::write_file_0600(path, &json)
2314}
2315
2316/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
2317/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
2318/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
2319/// in-memory cache is authoritative for the running daemon and losing the warm
2320/// start only costs one extra poll after the next restart.
2321fn persist_pr_cache(
2322    path: &Path,
2323    pr_cache: &PrStatusCache,
2324    watched: &[PrWatch],
2325    polled_at: DateTime<Utc>,
2326) {
2327    let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2328    if let Err(err) = write_pr_cache(path, &prefs) {
2329        let at = path.display();
2330        tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2331    }
2332}
2333
2334/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
2335/// the watch set the previous daemon last resolved and when it last polled. The
2336/// poller seeds its loop from this so a restart within the backoff window skips
2337/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
2338/// call to re-derive what the `0600` file already carries.
2339#[derive(Debug, Clone)]
2340struct PrWarmStart {
2341    /// The `(target, upstream_sha)` set the persisted verdicts describe.
2342    watched: Vec<PrWatch>,
2343    /// When those verdicts were resolved, used to age the warm start against the
2344    /// backoff (a stale-enough file just re-polls).
2345    polled_at: DateTime<Utc>,
2346}
2347
2348// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
2349
2350/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
2351/// fetched, for TTL expiry.
2352#[derive(Debug, Clone)]
2353struct OpenPrEntry {
2354    at: Instant,
2355    prs: Vec<Value>,
2356}
2357
2358/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
2359///
2360/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
2361/// from the daemon means N windows asking about one repo dedupe to a single counted
2362/// `gh pr list` within the TTL, instead of each window shelling its own (the
2363/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
2364/// the access pattern is a manual action (or a brief post-enable transient), so two
2365/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
2366/// and harmless, while the common repeat-within-TTL is served for free. The lock is
2367/// never held across an `.await`.
2368#[derive(Debug)]
2369struct OpenPrCache {
2370    entries: Mutex<HashMap<String, OpenPrEntry>>,
2371    ttl: Duration,
2372}
2373
2374impl OpenPrCache {
2375    fn new(ttl: Duration) -> Self {
2376        Self {
2377            entries: Mutex::new(HashMap::new()),
2378            ttl,
2379        }
2380    }
2381
2382    /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
2383    /// `None` (a miss that the caller resolves with a fresh `gh`).
2384    fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2385        self.entries
2386            .lock()
2387            .unwrap_or_else(PoisonError::into_inner)
2388            .get(key)
2389            .filter(|e| e.at.elapsed() < self.ttl)
2390            .map(|e| e.prs.clone())
2391    }
2392
2393    /// Records a freshly-fetched PR list for `key`.
2394    fn store(&self, key: String, prs: Vec<Value>) {
2395        self.entries
2396            .lock()
2397            .unwrap_or_else(PoisonError::into_inner)
2398            .insert(
2399                key,
2400                OpenPrEntry {
2401                    at: Instant::now(),
2402                    prs,
2403                },
2404            );
2405    }
2406}
2407
2408/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
2409/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
2410/// call on a blocking thread, never an async worker. The array is forwarded to the
2411/// extension verbatim, which parses it into its `PullRequest` shape.
2412fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2413    let output = crate::github_metrics::run_gh(
2414        bin,
2415        [
2416            "pr",
2417            "list",
2418            "--repo",
2419            slug,
2420            "--state",
2421            "open",
2422            "--json",
2423            OPEN_PR_JSON_FIELDS,
2424            "--limit",
2425            OPEN_PR_LIST_LIMIT,
2426        ],
2427        "pr list",
2428        None,
2429    )
2430    .with_context(|| {
2431        format!(
2432            "failed to run {} (is the GitHub CLI installed?)",
2433            bin.display()
2434        )
2435    })?;
2436    if !output.status.success() {
2437        let stderr = String::from_utf8_lossy(&output.stderr);
2438        bail!("gh pr list failed: {}", stderr.trim());
2439    }
2440    match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2441        Value::Array(arr) => Ok(arr),
2442        _ => bail!("gh pr list did not return a JSON array"),
2443    }
2444}
2445
2446/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
2447/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
2448/// op and the tray menu, both bounded to the (few) open windows, where the extra
2449/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
2450/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
2451fn git_status(folder: &Path) -> GitStatus {
2452    git_status_impl(folder, true)
2453}
2454
2455/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
2456/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
2457/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
2458/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
2459/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
2460/// fields stay `None`, so they are omitted on the wire exactly as for a branch
2461/// with no upstream.
2462fn git_status_cheap(folder: &Path) -> GitStatus {
2463    git_status_impl(folder, false)
2464}
2465
2466/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
2467/// repository that contains `folder` — so a subdirectory or a linked worktree both
2468/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
2469/// divergence. Every failure mode degrades to an empty status rather than
2470/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
2471fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2472    let Ok(repo) = Repository::discover(folder) else {
2473        return GitStatus::default();
2474    };
2475    // Repo identity applies even when HEAD is unborn or detached, so a worktree
2476    // still names its parent repo (and is flagged as a worktree) in those states.
2477    // The in-progress operation is read here too, for the same reason: a worktree
2478    // mid-rebase has a *detached* HEAD, so reading it any later would miss the one
2479    // state the cue exists to show.
2480    let base = GitStatus {
2481        main_repo: main_repo_name(repo.commondir()),
2482        is_worktree: repo.is_worktree(),
2483        operation: operation_slug(repo.state()),
2484        ..GitStatus::default()
2485    };
2486    let Ok(head) = repo.head() else {
2487        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
2488        return base;
2489    };
2490    // Resolved here — before the branch filter below, so a detached HEAD still
2491    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
2492    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
2493    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
2494    // rebuild (#1306's bar).
2495    let base = GitStatus {
2496        head_sha: head.target().map(|oid| oid.to_string()),
2497        ..base
2498    };
2499    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
2500    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
2501    // name — degrades to no branch through this one path.
2502    let Some(name) = head
2503        .shorthand()
2504        .ok()
2505        .filter(|_| head.is_branch())
2506        .map(str::to_string)
2507    else {
2508        return base;
2509    };
2510    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
2511    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
2512    // below costs the cheap path nothing, and is what gives it a handle to
2513    // resolve the upstream from.
2514    let branch = git2::Branch::wrap(head);
2515    // Unlike the divergence walk, this rides both paths: it is what makes a push
2516    // a visible delta (#1344).
2517    let upstream_sha = upstream_target(&branch);
2518    // The divergence walk is the dominant per-worktree cost, so the cheap path
2519    // skips it and leaves ahead/behind absent.
2520    let (ahead, behind) = if with_ahead_behind {
2521        match upstream_ahead_behind(&repo, &branch) {
2522            Some((ahead, behind)) => (Some(ahead), Some(behind)),
2523            None => (None, None),
2524        }
2525    } else {
2526        (None, None)
2527    };
2528    GitStatus {
2529        branch: Some(name),
2530        upstream_sha,
2531        ahead,
2532        behind,
2533        ..base
2534    }
2535}
2536
2537/// The stable kebab-case slug for a repository's in-progress operation, or `None`
2538/// when it is [`RepositoryState::Clean`] (#1415).
2539///
2540/// The three rebase flavours libgit2 distinguishes (`Rebase`, `RebaseInteractive`,
2541/// `RebaseMerge`) all collapse to `rebase-interactive` or `rebase`, because the
2542/// distinction is an implementation detail of how git is driving the rebase and
2543/// says nothing a user acting on the row would do differently. The `*Sequence`
2544/// variants likewise fold into their singular form. Everything a client does not
2545/// recognise still renders as "some operation in progress", which is the useful
2546/// floor.
2547fn operation_slug(state: RepositoryState) -> Option<String> {
2548    let slug = match state {
2549        RepositoryState::Clean => return None,
2550        RepositoryState::Merge => "merge",
2551        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2552        RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2553        RepositoryState::Bisect => "bisect",
2554        RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2555        RepositoryState::RebaseInteractive => "rebase-interactive",
2556        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2557    };
2558    Some(slug.to_string())
2559}
2560
2561/// The commit `branch`'s configured upstream ref points at, or `None` when it
2562/// tracks no upstream (or the ref is unresolvable).
2563///
2564/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
2565/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
2566/// refs read, but still **no revwalk and no object lookup**, which is the bar
2567/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
2568/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
2569/// same OID, so it is proven reachable.
2570fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2571    Some(branch.upstream().ok()?.get().target()?.to_string())
2572}
2573
2574/// The ahead/behind divergence of `folder`'s checked-out branch versus its
2575/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
2576/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
2577/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
2578/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
2579/// sync indicator.
2580fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2581    let repo = Repository::discover(folder).ok()?;
2582    let head = repo.head().ok()?;
2583    if !head.is_branch() {
2584        return None;
2585    }
2586    let branch = git2::Branch::wrap(head);
2587    upstream_ahead_behind(&repo, &branch)
2588}
2589
2590/// Commits `folder`'s checked-out branch is behind the repository's remote
2591/// default branch (`origin/<main>`), computed on demand for the lazy
2592/// `ahead-behind` op (#1457). Resolves the default branch the same
2593/// **local-only, no-fetch** way `worktree_rebase::resolve_onto` resolves its
2594/// `--onto` default — via [`RemoteInfo::detect_main_branch_local`] — but,
2595/// unlike that resolver, never falls back to a hardcoded `"main"`: this is a
2596/// passive signal, so an unresolvable default branch means silence (`None`)
2597/// rather than a guess.
2598///
2599/// `None` when: `folder` is not a repo, HEAD is detached/unborn, no default
2600/// branch is locally resolvable, or the branch's own upstream **is** already
2601/// that default branch — in which case [`folder_ahead_behind`]'s `behind`
2602/// already reports this exact divergence, so repeating it here would just
2603/// duplicate the existing sync count.
2604fn folder_main_behind(folder: &Path) -> Option<usize> {
2605    let repo = Repository::discover(folder).ok()?;
2606    let head = repo.head().ok()?;
2607    if !head.is_branch() {
2608        return None;
2609    }
2610    let branch = git2::Branch::wrap(head);
2611
2612    let remote = "origin";
2613    let default_branch = RemoteInfo::detect_main_branch_local(&repo, remote)?;
2614    let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2615
2616    // Skip when the branch's own upstream already IS the resolved default
2617    // branch (the common checked-out-main/master case) — compared by ref name
2618    // so a fork's upstream on a *different* remote (e.g. `upstream/main`) is
2619    // never mistaken for it.
2620    if let Ok(upstream) = branch.upstream() {
2621        if upstream.get().name() == Ok(onto_ref.as_str()) {
2622            return None;
2623        }
2624    }
2625
2626    let head_oid = branch.get().target()?;
2627    let onto_oid = repo
2628        .revparse_single(&onto_ref)
2629        .ok()?
2630        .peel_to_commit()
2631        .ok()?
2632        .id();
2633    let (_ahead, behind) = repo.graph_ahead_behind(head_oid, onto_oid).ok()?;
2634    Some(behind)
2635}
2636
2637/// The main repository's directory name from git's common dir. For the usual
2638/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
2639/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
2640/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
2641/// no name can be derived.
2642fn main_repo_name(commondir: &Path) -> Option<String> {
2643    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2644    if file_name == ".git" {
2645        // Normal layout: the repo is the directory that contains `.git`.
2646        commondir
2647            .parent()
2648            .and_then(Path::file_name)
2649            .map(|n| n.to_string_lossy().into_owned())
2650    } else {
2651        // A bare repo: use its own directory name, without any `.git` suffix.
2652        Some(
2653            file_name
2654                .strip_suffix(".git")
2655                .unwrap_or(&file_name)
2656                .to_string(),
2657        )
2658    }
2659}
2660
2661/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2662/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2663fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2664    let upstream = branch.upstream().ok()?;
2665    let local_oid = branch.get().target()?;
2666    let upstream_oid = upstream.get().target()?;
2667    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2668}
2669
2670/// The wire shape of an enriched window: the stored entry fields plus the
2671/// daemon-computed git state, flattened into one JSON object. Serializing
2672/// through a single struct (rather than mutating a `Value`) keeps every present
2673/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2674/// the absent git fields — no manual per-field insertion.
2675#[derive(Serialize)]
2676struct EnrichedEntry<'a> {
2677    #[serde(flatten)]
2678    entry: &'a WindowEntry,
2679    #[serde(flatten)]
2680    git: GitStatus,
2681}
2682
2683/// Serializes a registry entry and folds in the live [`git_status`] of its
2684/// primary (first) folder, producing the JSON object served on the wire
2685/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2686/// enriched — it is the one the table shows and the "focus" action opens.
2687fn enriched_entry(entry: &WindowEntry) -> Value {
2688    let git = entry
2689        .folders
2690        .first()
2691        .map(|folder| git_status(folder))
2692        .unwrap_or_default();
2693    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2694}
2695
2696/// Enriches a batch of entries with their git state on a blocking thread, since
2697/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2698/// handler. A join failure degrades to an empty list rather than erroring.
2699async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2700    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2701        .await
2702        .unwrap_or_default()
2703}
2704
2705// --- Repo/worktree tree (#1265) ----------------------------------------------
2706
2707/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2708/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2709/// non-GitHub (or remote-less) repo omits it.
2710#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2711struct GithubIdentity {
2712    /// The repository owner (user or org) — the first path segment.
2713    owner: String,
2714    /// The repository name, with any `.git` suffix stripped.
2715    name: String,
2716}
2717
2718/// One worktree of a repository in the `tree` payload: its path, live git state,
2719/// whether it is the main working tree, and whether a VS Code window currently
2720/// has it open (with that window's key, for the focus action). Optional git
2721/// fields degrade independently, exactly like [`GitStatus`].
2722///
2723/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2724/// the dominant per-worktree cost when computed eagerly for every worktree on
2725/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2726/// worktrees a client actually shows (#1306).
2727///
2728/// The two **OIDs** the divergence is computed from — `head_sha` and
2729/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2730/// refs read rather than a commit-graph walk, and between them they are what makes
2731/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2732/// re-ask for the counts it left behind.
2733#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2734struct TreeWorktree {
2735    /// Absolute path to the worktree's working directory.
2736    path: String,
2737    /// The checked-out branch, or `None` when detached or unborn.
2738    #[serde(skip_serializing_if = "Option::is_none")]
2739    branch: Option<String>,
2740    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2741    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2742    /// commit a visible delta, so a push re-renders instead of being dropped by the
2743    /// server's snapshot diff (#1337).
2744    #[serde(skip_serializing_if = "Option::is_none")]
2745    head_sha: Option<String>,
2746    /// The commit the branch's upstream ref points at, or `None` without an
2747    /// upstream. The push counterpart of `head_sha`: a push moves only
2748    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2749    /// making the snapshot a real delta the server's diff cannot drop, which is
2750    /// what re-fetches the lazy ahead/behind (#1344).
2751    #[serde(skip_serializing_if = "Option::is_none")]
2752    upstream_sha: Option<String>,
2753    /// Whether this is the repository's main working tree (vs a linked worktree).
2754    is_main: bool,
2755    /// Whether a live VS Code window currently has this worktree open.
2756    open: bool,
2757    /// The open window's registry key, when `open` — the handle a focus action
2758    /// resolves. Absent for a worktree with no open window.
2759    #[serde(skip_serializing_if = "Option::is_none")]
2760    window_key: Option<String>,
2761    /// The open PR whose head is this worktree's branch, with its CI verdict
2762    /// (#1337). Resolved by the daemon's background poller and folded on as the
2763    /// snapshot is built, so every open window sees the same live state without
2764    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2765    /// with no open PR (see `pr_none`), or until the first poll lands.
2766    #[serde(skip_serializing_if = "Option::is_none")]
2767    pr: Option<PrBadge>,
2768    /// Set when the daemon **checked GitHub and found no open PR** for this
2769    /// worktree's branch — the explicit negative (#1370), mutually exclusive
2770    /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2771    /// non-GitHub worktree, and — crucially — while the branch is simply **not
2772    /// yet resolved** (before the first poll lands, or ever, on a failed one):
2773    /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2774    /// older client stays byte-identical (ADR-0053). Clients use it to keep
2775    /// their degraded per-window `gh pr list` fallback quiet for branches the
2776    /// daemon has already answered for.
2777    #[serde(skip_serializing_if = "is_false")]
2778    pr_none: bool,
2779    /// The multi-step git operation this worktree is mid-way through, when any
2780    /// (#1415) — see [`GitStatus::operation`]. The **durable** half of the rebase
2781    /// cue: a conflict the `rebase` op left in place shows here until it is
2782    /// resolved, across daemon restarts. Omitted for a clean worktree.
2783    #[serde(skip_serializing_if = "Option::is_none")]
2784    operation: Option<String>,
2785    /// Whether the daemon is rebasing this worktree **right now** (#1415) — the
2786    /// **transient** half of the cue, from the registry's in-memory set.
2787    ///
2788    /// Not redundant with `operation`: a rebase that applies cleanly never leaves
2789    /// an on-disk state for `operation` to report, and even one that conflicts
2790    /// only writes it at the moment of collision — so without this a multi-second
2791    /// rebase would render as nothing happening at all. Omitted (false) for the
2792    /// common case, keeping an older client byte-identical.
2793    #[serde(skip_serializing_if = "is_false")]
2794    rebasing: bool,
2795    /// Whether the daemon is pushing this worktree **right now** (#1443) — the
2796    /// `rebasing` twin, from the registry's other in-flight set.
2797    ///
2798    /// Unlike a rebase this cue has **no durable half**: a push writes no on-disk
2799    /// state, so there is nothing for a later snapshot to rediscover and this flag
2800    /// is the whole of it. A *completed* push instead shows up as `upstream_sha`
2801    /// moving, which is why that field rides the snapshot (#1344). Omitted (false)
2802    /// for the common case, keeping an older client byte-identical.
2803    #[serde(skip_serializing_if = "is_false")]
2804    pushing: bool,
2805}
2806
2807/// The registry's two transient in-flight sets, read together into one tree
2808/// snapshot (#1443).
2809///
2810/// Grouped rather than threaded as two parameters through
2811/// [`worktree_entry`]/[`repo_tree`]/[`build_tree`]/[`tree_repos`]: they are always
2812/// read at the same moment, from the same registry, for the same snapshot, and a
2813/// third would otherwise mean a fifth positional `HashSet` at every level.
2814#[derive(Debug, Clone, Default)]
2815struct InFlight {
2816    /// Worktree paths the `rebase` op is executing on (#1415).
2817    rebasing: HashSet<PathBuf>,
2818    /// Worktree paths the `push` op is executing on (#1443).
2819    pushing: HashSet<PathBuf>,
2820}
2821
2822impl InFlight {
2823    /// Reads both sets off the registry. Two short lock acquisitions, neither held
2824    /// across an `.await`; the pair need not be atomic, since each cue is
2825    /// independently true or false of a given row.
2826    fn read(registry: &WorktreesRegistry) -> Self {
2827        Self {
2828            rebasing: registry.rebasing_paths(),
2829            pushing: registry.pushing_paths(),
2830        }
2831    }
2832}
2833
2834/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2835/// derived from the distinct open windows; a repo leaves the tree when its last
2836/// window closes (the open-window-derived model, ADR-0040 / #1264).
2837#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2838struct TreeRepo {
2839    /// The main repository's directory name (see [`main_repo_name`]).
2840    main_repo: String,
2841    /// The GitHub identity of `origin`, when it is a `github.com` remote.
2842    #[serde(skip_serializing_if = "Option::is_none")]
2843    github: Option<GithubIdentity>,
2844    /// Absolute path to the main working tree — the repo's root.
2845    root: String,
2846    /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2847    /// registry's per-repo enable set, which defaults **off**, so it is omitted
2848    /// (false) for the common not-polled repo — keeping older clients
2849    /// byte-identical — and present (`true`) only for a repo the user has
2850    /// explicitly enabled. The extension colours the repo icon green when set and
2851    /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2852    /// on it so a not-polled repo issues zero `gh`.
2853    #[serde(skip_serializing_if = "is_false")]
2854    polling_enabled: bool,
2855    /// Every worktree of the repo: the main working tree first, then linked
2856    /// worktrees sorted by path.
2857    worktrees: Vec<TreeWorktree>,
2858}
2859
2860/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2861/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2862/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2863/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2864/// slashes are stripped; anything with an empty or extra path segment is
2865/// rejected (best-effort, never panics).
2866fn github_identity(url: &str) -> Option<GithubIdentity> {
2867    let url = url.trim();
2868    // Reduce every supported form to the `owner/name…` tail after the host.
2869    let rest = [
2870        "https://github.com/",
2871        "http://github.com/",
2872        "ssh://git@github.com/",
2873        "git://github.com/",
2874        "git@github.com:",
2875    ]
2876    .iter()
2877    .find_map(|prefix| url.strip_prefix(prefix))?;
2878    let rest = rest.strip_suffix(".git").unwrap_or(rest);
2879    let rest = rest.trim_end_matches('/');
2880    let mut parts = rest.splitn(2, '/');
2881    let owner = parts.next()?.trim();
2882    let name = parts.next()?.trim();
2883    // A well-formed identity has exactly two non-empty segments.
2884    if owner.is_empty() || name.is_empty() || name.contains('/') {
2885        return None;
2886    }
2887    Some(GithubIdentity {
2888        owner: owner.to_string(),
2889        name: name.to_string(),
2890    })
2891}
2892
2893/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2894/// `github.com` remote found. `None` when no remote is a GitHub remote.
2895fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2896    if let Ok(origin) = repo.find_remote("origin") {
2897        if let Some(id) = origin.url().ok().and_then(github_identity) {
2898            return Some(id);
2899        }
2900    }
2901    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2902    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2903    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2904    let names = repo.remotes().ok();
2905    names
2906        .iter()
2907        .flat_map(|arr| arr.iter())
2908        .flatten()
2909        .flatten()
2910        .filter_map(|name| repo.find_remote(name).ok())
2911        .find_map(|remote| remote.url().ok().and_then(github_identity))
2912}
2913
2914/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2915/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2916/// no longer exists) so the join still degrades gracefully.
2917fn canonical(path: &Path) -> PathBuf {
2918    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2919}
2920
2921/// Indexes the open windows by canonicalized workspace-folder path → window key,
2922/// so a worktree path can be joined back to the window (if any) that has it open.
2923/// The first window wins a shared folder; `entries` arrive in a deterministic
2924/// (repo, key) order, so the choice is stable.
2925fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2926    let mut index = HashMap::new();
2927    for entry in entries {
2928        for folder in &entry.folders {
2929            index
2930                .entry(canonical(folder))
2931                .or_insert_with(|| entry.key.clone());
2932        }
2933    }
2934    index
2935}
2936
2937/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2938/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2939/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2940/// by the caller from the enumeration (main working tree vs linked).
2941fn worktree_entry(
2942    path: &Path,
2943    is_main: bool,
2944    open_index: &HashMap<PathBuf, String>,
2945    in_flight: &InFlight,
2946) -> TreeWorktree {
2947    let status = git_status_cheap(path);
2948    let canonical = canonical(path);
2949    let window_key = open_index.get(&canonical).cloned();
2950    TreeWorktree {
2951        path: path.display().to_string(),
2952        branch: status.branch,
2953        head_sha: status.head_sha,
2954        upstream_sha: status.upstream_sha,
2955        is_main,
2956        open: window_key.is_some(),
2957        window_key,
2958        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2959        // identity — known one level up, in `repo_tree`.
2960        pr: None,
2961        pr_none: false,
2962        operation: status.operation,
2963        // Every in-flight cue is joined on the *canonical* path: the registry sets
2964        // are canonicalized by the adapter when an op marks them.
2965        rebasing: in_flight.rebasing.contains(&canonical),
2966        pushing: in_flight.pushing.contains(&canonical),
2967    }
2968}
2969
2970/// Folds the poller's cached PR resolutions onto each worktree of each repo
2971/// (#1337).
2972///
2973/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2974/// identity, branch) and the identity is only known once the repo is assembled.
2975/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2976/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
2977/// keeps `pr: None`/`pr_none: false` and renders nothing.
2978///
2979/// A verdict computed for a **different commit** than the worktree has checked out
2980/// is downgraded to pending here rather than shown as-is. That is what makes a push
2981/// invalidate the badge the moment it happens: the cache still holds the previous
2982/// commit's verdict, and this fold — which runs on every snapshot — notices without
2983/// waiting for a poll. Without it the previous head's `✓` stands until the poller
2984/// next runs, which is up to the full backoff.
2985///
2986/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
2987/// when `head_sha` moves: it has no commit to be stale against, and dropping it
2988/// would re-arm every client's `gh` fallback on every local commit. The poller's
2989/// `moved` trigger re-checks the branch within one fast poll anyway.
2990/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
2991/// enable set (#1376).
2992///
2993/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
2994/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
2995/// enabled carries neither the flag nor any badge. Purely a set membership check —
2996/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
2997/// and stays `false`; it never polls anyway.
2998fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2999    for repo in repos {
3000        if let Some(github) = &repo.github {
3001            repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3002        }
3003    }
3004}
3005
3006fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3007    for repo in repos {
3008        // A not-polled repo (#1376) never carries a badge: skip it so a repo the
3009        // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
3010        // the flag, and so the icon greys cross-window on the next pushed snapshot.
3011        if !repo.polling_enabled {
3012            continue;
3013        }
3014        let Some(github) = repo.github.clone() else {
3015            continue;
3016        };
3017        for worktree in &mut repo.worktrees {
3018            let Some(branch) = &worktree.branch else {
3019                continue;
3020            };
3021            match pr_cache.get(&github.owner, &github.name, branch) {
3022                Some(PrResolution::Pr(mut badge)) => {
3023                    if badge.is_stale_for(worktree.head_sha.as_deref()) {
3024                        badge.checks = PrCheckState::Pending;
3025                    }
3026                    worktree.pr = Some(badge);
3027                }
3028                Some(PrResolution::NoPr) => worktree.pr_none = true,
3029                None => {}
3030            }
3031        }
3032    }
3033}
3034
3035/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
3036/// handle discovered from one of its folders. Opens the **main** repo from the
3037/// shared common dir's parent so the main working tree and every linked worktree
3038/// are enumerated regardless of which one seeded the discovery. `None` for a
3039/// bare or otherwise root-less repo (no working tree to show).
3040fn repo_tree(
3041    discovered: &Repository,
3042    open_index: &HashMap<PathBuf, String>,
3043    in_flight: &InFlight,
3044) -> Option<TreeRepo> {
3045    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
3046    // linked worktrees; its parent is the main working tree.
3047    let commondir = canonical(discovered.commondir());
3048    let main_root = commondir.parent()?.to_path_buf();
3049    let main_repo = Repository::open(&main_root).ok()?;
3050
3051    // Main working tree first.
3052    let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3053    // Then every linked worktree, sorted by path for deterministic output. The
3054    // `StringArray` of names is bound so `iter()` can borrow it (only
3055    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
3056    // worktree is skipped.
3057    let names = main_repo.worktrees().ok();
3058    let mut linked: Vec<PathBuf> = names
3059        .iter()
3060        .flat_map(|arr| arr.iter())
3061        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3062        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3063        .filter_map(|name| main_repo.find_worktree(name).ok())
3064        .map(|wt| wt.path().to_path_buf())
3065        .collect();
3066    linked.sort();
3067    worktrees.extend(
3068        linked
3069            .iter()
3070            .map(|path| worktree_entry(path, false, open_index, in_flight)),
3071    );
3072
3073    Some(TreeRepo {
3074        main_repo: main_repo_name(&commondir)?,
3075        github: remote_github_identity(&main_repo),
3076        root: main_root.display().to_string(),
3077        // Defaults off; `stamp_polling` sets it from the registry's enable set
3078        // once the repo (and thus its GitHub identity) is assembled.
3079        polling_enabled: false,
3080        worktrees,
3081    })
3082}
3083
3084/// Resolves the seed `folders` to their distinct repositories and enumerates
3085/// each repo's worktrees. Dedupes repos by their common dir (shared across a
3086/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
3087/// is not in a git repo is skipped. Pure blocking git I/O — call it via
3088/// [`tree_repos`], never under the registry lock.
3089fn build_tree(
3090    folders: Vec<PathBuf>,
3091    windows: Vec<WindowEntry>,
3092    in_flight: InFlight,
3093) -> Vec<TreeRepo> {
3094    let open_index = open_window_index(&windows);
3095    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3096    for folder in &folders {
3097        let Ok(repo) = Repository::discover(folder) else {
3098            continue;
3099        };
3100        let key = canonical(repo.commondir());
3101        if repos.contains_key(&key) {
3102            continue;
3103        }
3104        if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3105            repos.insert(key, tree);
3106        }
3107    }
3108    repos.into_values().collect()
3109}
3110
3111/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
3112/// does synchronous disk I/O and this runs inside the async control-socket
3113/// handler), returning the serialized `repos` array. A join failure degrades to
3114/// an empty list rather than erroring, matching [`enriched_windows`].
3115async fn tree_repos(
3116    folders: Vec<PathBuf>,
3117    windows: Vec<WindowEntry>,
3118    pr_cache: Arc<PrStatusCache>,
3119    enabled_polling: HashSet<String>,
3120    in_flight: InFlight,
3121) -> Vec<Value> {
3122    tokio::task::spawn_blocking(move || {
3123        let mut repos = build_tree(folders, windows, in_flight);
3124        // Stamp per-repo poll state first so `fold_pr_badges` can skip a
3125        // not-polled repo — a disabled repo carries neither the flag nor a badge.
3126        stamp_polling(&mut repos, &enabled_polling);
3127        fold_pr_badges(&mut repos, &pr_cache);
3128        repos
3129            .iter()
3130            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3131            .collect()
3132    })
3133    .await
3134    .unwrap_or_default()
3135}
3136
3137// --- Lazy ahead/behind (#1306) -----------------------------------------------
3138
3139/// The wire shape of one worktree's lazily-fetched divergence: `ahead`/`behind`
3140/// (from its own upstream, folded in together or not at all — they come from one
3141/// `graph_ahead_behind` call) and `main_behind` (from the repo's remote default
3142/// branch, #1457), each independently optional. `main_behind` can be present
3143/// when `ahead`/`behind` are absent (no upstream at all) or absent when they are
3144/// present (the branch's own upstream already *is* the default branch).
3145#[derive(Serialize)]
3146struct AheadBehindEntry {
3147    #[serde(skip_serializing_if = "Option::is_none")]
3148    ahead: Option<usize>,
3149    #[serde(skip_serializing_if = "Option::is_none")]
3150    behind: Option<usize>,
3151    #[serde(skip_serializing_if = "Option::is_none")]
3152    main_behind: Option<usize>,
3153}
3154
3155/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
3156/// returning a JSON object keyed by the **requested** path string:
3157/// `{ "<path>": { "ahead"?, "behind"?, "main_behind"? }, … }`. A row is **omitted**
3158/// when neither the upstream divergence nor the main-branch divergence resolves
3159/// (not a repo, detached/unborn HEAD) — the client renders it without a sync
3160/// indicator, exactly as before #1457. A row can otherwise carry any subset of the
3161/// three fields: `main_behind` alone (no upstream, but behind the default branch),
3162/// `ahead`/`behind` alone (upstream *is* the default branch, so `main_behind` is
3163/// skipped), or all three together.
3164///
3165/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
3166/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
3167/// shows (the extension on expand), not for every worktree on every tick. The git
3168/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
3169/// degrades to an empty object rather than erroring.
3170async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
3171    tokio::task::spawn_blocking(move || {
3172        let mut results = serde_json::Map::new();
3173        for path in paths {
3174            let (ahead, behind) =
3175                folder_ahead_behind(&path).map_or((None, None), |(a, b)| (Some(a), Some(b)));
3176            let main_behind = folder_main_behind(&path);
3177            if ahead.is_none() && main_behind.is_none() {
3178                continue;
3179            }
3180            results.insert(
3181                path.display().to_string(),
3182                json!(AheadBehindEntry {
3183                    ahead,
3184                    behind,
3185                    main_behind,
3186                }),
3187            );
3188        }
3189        Value::Object(results)
3190    })
3191    .await
3192    .unwrap_or_else(|_| json!({}))
3193}
3194
3195// --- Push subscription (#1267) -----------------------------------------------
3196
3197/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
3198/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
3199/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
3200/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
3201/// type only has to (a) relay the registry's change-notify and (b) read the
3202/// tree snapshot on demand.
3203///
3204/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
3205/// is built at most once per tick and fanned out, rather than each stream
3206/// rebuilding the identical tree. This type holds only cheap handles — a clone
3207/// of the shared cache and its own change-notify receiver.
3208struct WorktreesStream {
3209    /// The shared coalescing cache the snapshot is read through, so every
3210    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
3211    cache: Arc<TreeSnapshotCache>,
3212    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
3213    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
3214    /// server's diff drops any snapshot that ends up identical.
3215    changes: watch::Receiver<u64>,
3216}
3217
3218#[async_trait]
3219impl ServiceStream for WorktreesStream {
3220    async fn changed(&mut self) {
3221        // `watch::Receiver::changed` marks the newest version seen, so a burst of
3222        // bumps collapses into a single wakeup. If every sender is gone (the
3223        // registry — and thus the daemon — is tearing down) it returns `Err`;
3224        // park instead of returning, so this arm can never spin the server's
3225        // `select!` (the tick and shutdown arms still drive teardown).
3226        if self.changes.changed().await.is_err() {
3227            std::future::pending::<()>().await;
3228        }
3229    }
3230
3231    async fn snapshot(&self) -> Value {
3232        // Read through the shared coalescing cache. The value is built by the
3233        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
3234        // live push agree byte-for-byte — but here it is built once per tick and
3235        // shared across every subscriber rather than rebuilt per stream (#1303).
3236        self.cache.snapshot().await
3237    }
3238}
3239
3240/// A coalescing cache for the global tree snapshot (#1303).
3241///
3242/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
3243/// server re-samples each on its own `STREAM_TICK` and on every registry change
3244/// — so with N windows the *identical* global tree was being built N times per
3245/// tick. This cache collapses that to **one** build: all streams share it, and
3246/// it rebuilds at most once per `ttl` (the stream tick) per registry
3247/// change-generation.
3248///
3249/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
3250/// exactly as before:
3251/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
3252///   still matches — a `register`/`unregister`/toggle bumps it and forces a
3253///   fresh build, so subscribers never see a stale visible set; and
3254/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
3255///   branch switch, new commits), which fires no registry event, still surfaces
3256///   within one tick.
3257///
3258/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
3259/// callers, so a burst of N streams waking on the same tick/change performs one
3260/// build while the rest wait and read the shared result. The one-shot `tree` op
3261/// bypasses this and computes fresh — it is a rare manual refresh, not part of
3262/// the per-tick fan-out.
3263struct TreeSnapshotCache {
3264    /// The registry every snapshot is built from, and whose change-generation
3265    /// gates cache reuse.
3266    registry: Arc<WorktreesRegistry>,
3267    /// PR badges folded onto each worktree as the snapshot is built (#1337).
3268    /// Written by the background poller; read here. A miss simply omits `pr`.
3269    pr_cache: Arc<PrStatusCache>,
3270    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
3271    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
3272    /// coalesced build runs at most once per tick; tests inject a shorter value.
3273    ttl: Duration,
3274    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
3275    /// because it is deliberately held across the `.await` of the git
3276    /// enumeration, so concurrent callers serialize onto one build rather than
3277    /// each computing their own.
3278    state: AsyncMutex<Option<CachedTree>>,
3279    /// How many times the tree was actually (re)built — so tests can assert the
3280    /// coalescing collapses an N-stream burst into one build. Cheap and always
3281    /// maintained; only read under `#[cfg(test)]`.
3282    computes: AtomicU64,
3283}
3284
3285/// One cached tree snapshot: the shared value plus the two freshness stamps
3286/// [`TreeSnapshotCache`] checks before reusing it.
3287struct CachedTree {
3288    /// The registry change-generation captured *before* the build, so a change
3289    /// racing the build advances the generation and the next read rebuilds
3290    /// (conservative: it may rebuild once needlessly, but never serves stale).
3291    generation: u64,
3292    /// When the value was built, for the `ttl` staleness check.
3293    computed_at: Instant,
3294    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
3295    /// subscriber by cloning the `Arc`'s inner value.
3296    value: Arc<Value>,
3297}
3298
3299impl TreeSnapshotCache {
3300    /// Creates a cache over `registry` with the default TTL — the server's
3301    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
3302    /// build runs at most once per tick.
3303    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3304        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3305    }
3306
3307    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
3308    /// long) freshness window without waiting a real tick.
3309    fn with_ttl(
3310        registry: Arc<WorktreesRegistry>,
3311        pr_cache: Arc<PrStatusCache>,
3312        ttl: Duration,
3313    ) -> Self {
3314        Self {
3315            registry,
3316            pr_cache,
3317            ttl,
3318            state: AsyncMutex::new(None),
3319            computes: AtomicU64::new(0),
3320        }
3321    }
3322
3323    /// The current tree snapshot, built at most once per `ttl` per registry
3324    /// change-generation and shared across all callers. See the type docs for
3325    /// the freshness and single-flight semantics.
3326    async fn snapshot(&self) -> Value {
3327        // Hold the lock across the whole check-and-build so concurrent callers
3328        // serialize onto one build (single-flight); reading the generation here
3329        // (before the build) means a change racing the build forces the *next*
3330        // read to rebuild rather than serving this now-stale value.
3331        let mut state = self.state.lock().await;
3332        let generation = self.registry.change_generation();
3333        // Reuse the cached value only while it matches the current generation
3334        // *and* is within the TTL; either failing forces a rebuild.
3335        let fresh = state.as_ref().and_then(|cached| {
3336            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3337                .then(|| Arc::clone(&cached.value))
3338        });
3339        let value = if let Some(value) = fresh {
3340            value
3341        } else {
3342            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3343            self.computes.fetch_add(1, Ordering::Relaxed);
3344            *state = Some(CachedTree {
3345                generation,
3346                computed_at: Instant::now(),
3347                value: Arc::clone(&value),
3348            });
3349            value
3350        };
3351        // Release the lock before the (deeper) clone of the shared value out.
3352        drop(state);
3353        (*value).clone()
3354    }
3355
3356    /// How many times the tree was actually built — the coalescing assertion in
3357    /// tests (N reads within one tick/generation should build once).
3358    #[cfg(test)]
3359    fn compute_count(&self) -> u64 {
3360        self.computes.load(Ordering::Relaxed)
3361    }
3362}
3363
3364/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
3365/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
3366/// (the seed folders to derive repos from, and the live windows to join on) and
3367/// a lock-free read of the toggle, then the git enumeration/enrichment off the
3368/// lock on a blocking thread inside [`tree_repos`].
3369async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3370    let folders = registry.open_folders();
3371    let windows = registry.list();
3372    let show_closed = registry.show_closed();
3373    let enabled_polling = registry.enabled_polling_repos();
3374    // The transient rebase (#1415) and push (#1443) cues, read here with the other
3375    // cheap registry locks so the git work below deals only in plain data.
3376    let in_flight = InFlight::read(registry);
3377    json!({
3378        "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3379        "show_closed": show_closed,
3380    })
3381}
3382
3383/// A short human name for a window: its repo, else its first folder's basename,
3384/// else a placeholder.
3385fn display_name(entry: &WindowEntry) -> String {
3386    if let Some(repo) = &entry.repo {
3387        return repo.clone();
3388    }
3389    if let Some(folder) = entry.folders.first() {
3390        return folder.file_name().map_or_else(
3391            || folder.display().to_string(),
3392            |n| n.to_string_lossy().into_owned(),
3393        );
3394    }
3395    "(no folder)".to_string()
3396}
3397
3398/// Separator between the repo name and branch for a normal working tree.
3399const REPO_SEP: char = '·';
3400/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
3401/// line is distinguishable at a glance from its parent repo's main checkout.
3402const WORKTREE_SEP: char = '⑂';
3403
3404/// The full tray item list for a window set: the "No open windows" placeholder
3405/// when empty, else one line per window via [`window_menu_items`]. Does the git
3406/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
3407/// background refresh task — and inline only as a cold-start fallback in `menu`.
3408fn menu_items_for(
3409    entries: &[WindowEntry],
3410    rate_limit: Option<&RateLimitSnapshot>,
3411) -> Vec<MenuItem> {
3412    let mut items = Vec::new();
3413    // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
3414    // above the windows, so an approaching exhaustion is visible in the tray before
3415    // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
3416    if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3417        if !label.is_empty() {
3418            items.push(MenuItem::Label(label));
3419            items.push(MenuItem::Separator);
3420        }
3421    }
3422    if entries.is_empty() {
3423        items.push(MenuItem::Label("No open windows".to_string()));
3424    } else {
3425        items.extend(window_menu_items(entries));
3426    }
3427    items
3428}
3429
3430/// Builds the tray items for a non-empty window list: **one clickable line per
3431/// window** whose label carries the live git state and whose click focuses that
3432/// window. A window with no workspace folder has nothing for `code` to open, so
3433/// it stays a non-clickable status line. The labels read each worktree from disk
3434/// (via [`window_label`]) — cheap for a realistic window count and consistent
3435/// with reap-on-read.
3436fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3437    entries
3438        .iter()
3439        .map(|entry| {
3440            let label = window_label(entry);
3441            if entry.folders.is_empty() {
3442                MenuItem::Label(label)
3443            } else {
3444                MenuItem::Action(MenuAction {
3445                    id: format!("focus:{}", entry.key),
3446                    label,
3447                    enabled: true,
3448                })
3449            }
3450        })
3451        .collect()
3452}
3453
3454/// The tray label for one window: the **main repository** name, then live branch
3455/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
3456/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
3457/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
3458/// that is not a repo falls back to its reported title.
3459fn window_label(entry: &WindowEntry) -> String {
3460    let status = entry
3461        .folders
3462        .first()
3463        .map(|folder| git_status(folder))
3464        .unwrap_or_default();
3465    // Prefer the git-derived main repo so a linked worktree names its parent
3466    // repository rather than its worktree-folder basename.
3467    let name = status
3468        .main_repo
3469        .clone()
3470        .unwrap_or_else(|| display_name(entry));
3471    if let Some(branch) = &status.branch {
3472        let sep = if status.is_worktree {
3473            WORKTREE_SEP
3474        } else {
3475            REPO_SEP
3476        };
3477        return match sync_indicator(status.ahead, status.behind) {
3478            Some(sync) => format!("{name} {sep} {branch} {sync}"),
3479            None => format!("{name} {sep} {branch}"),
3480        };
3481    }
3482    // No git branch (not a repo / detached): fall back to the reported title.
3483    match &entry.title {
3484        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3485        _ => name,
3486    }
3487}
3488
3489/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
3490/// has no upstream to compare against.
3491fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3492    match (ahead, behind) {
3493        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3494        _ => None,
3495    }
3496}
3497
3498/// Well-known absolute locations for the VS Code launcher, tried in order so a
3499/// daemon running under launchd (with a minimal `PATH`) still finds it.
3500const CODE_BINARY_CANDIDATES: &[&str] = &[
3501    "/usr/local/bin/code",
3502    "/opt/homebrew/bin/code",
3503    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3504    "/usr/bin/code",
3505];
3506
3507/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
3508/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
3509/// with the sessions service's tray "focus" action, which resolves a session to
3510/// its VS Code window folder and opens it through this same guarded launcher.
3511pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3512    focus_window_with(&resolve_code_binary(), folder)
3513}
3514
3515/// Spawns `program` on `folder` after validating the folder. Split out from
3516/// [`focus_window`] so the validation and spawn paths are testable with an
3517/// explicit launcher (no environment or installed-editor dependency).
3518///
3519/// Best-effort and non-blocking: the spawned child is reaped on a detached
3520/// thread so a long-lived daemon does not accumulate zombies one per focus.
3521fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3522    // The tray path passes an absolute workspace folder, but the socket `open`
3523    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
3524    // real check there, not just an assertion: requiring an absolute path also
3525    // rules out a `-`-leading path being parsed by `code` as a flag.
3526    if !folder.is_absolute() {
3527        bail!(
3528            "refusing to focus a non-absolute folder path: {}",
3529            folder.display()
3530        );
3531    }
3532    if !folder.is_dir() {
3533        bail!("worktree folder no longer exists: {}", folder.display());
3534    }
3535    // Detach the launcher's stdio so its output never interleaves into the
3536    // long-lived daemon's own stdout/stderr (or the test harness's).
3537    let child = Command::new(program)
3538        .arg(folder)
3539        .stdin(Stdio::null())
3540        .stdout(Stdio::null())
3541        .stderr(Stdio::null())
3542        .spawn()
3543        .with_context(|| {
3544            format!(
3545                "failed to launch `{}` to focus {}",
3546                program.display(),
3547                folder.display()
3548            )
3549        })?;
3550    // Reap the child without blocking so it never lingers as a zombie.
3551    std::thread::spawn(move || {
3552        let mut child = child;
3553        let _ = child.wait();
3554    });
3555    Ok(())
3556}
3557
3558/// Resolves the VS Code launcher from the real environment: the
3559/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
3560/// `code` on `PATH`. The pure resolution logic lives in
3561/// [`resolve_code_binary_from`] for testing.
3562fn resolve_code_binary() -> PathBuf {
3563    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3564}
3565
3566/// Pure launcher resolution: `env_override` wins; otherwise the first existing
3567/// `candidate`; otherwise bare `code`.
3568fn resolve_code_binary_from(
3569    env_override: Option<std::ffi::OsString>,
3570    candidates: &[&str],
3571) -> PathBuf {
3572    if let Some(path) = env_override {
3573        return PathBuf::from(path);
3574    }
3575    for candidate in candidates {
3576        let path = Path::new(candidate);
3577        if path.exists() {
3578            return path.to_path_buf();
3579        }
3580    }
3581    PathBuf::from("code")
3582}
3583
3584// --- Reposition op (#1407) ---------------------------------------------------
3585
3586/// The `reposition` op payload: move every target window onto the invoking
3587/// window's geometry.
3588///
3589/// Keyed by **window key**, not worktree path, because the subject is a *window*:
3590/// the geometry belongs to the OS window, and a path resolves to one only via the
3591/// registry. The CLI (which naturally speaks paths) maps them to keys itself
3592/// before sending.
3593#[derive(Debug, Clone, Deserialize)]
3594struct RepositionRequest {
3595    /// The invoking window's key. Supplies the frame; never moved itself.
3596    reference_key: String,
3597    /// The windows to move. May include `reference_key` — a multi-selection
3598    /// naturally contains the invoking window — which is reported and skipped.
3599    #[serde(default)]
3600    target_keys: Vec<String>,
3601    /// Resolve and report only, writing nothing (`worktrees reposition
3602    /// --dry-run`). The diagnostic surface for title matching.
3603    #[serde(default)]
3604    check: bool,
3605}
3606
3607/// Distils the live registration for `key` into what [`geometry`] matches on.
3608///
3609/// A key with no live window still yields a value, flagged `live: false`, so it
3610/// reports as a per-target `no-window` skip rather than vanishing from the batch —
3611/// a tree row can be a tick stale, and the user needs to see which of their
3612/// selection was ignored.
3613fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3614    entries.iter().find(|entry| entry.key == key).map_or_else(
3615        || geometry::RegisteredWindow {
3616            key: key.to_string(),
3617            live: false,
3618            title: None,
3619            pid: None,
3620        },
3621        |entry| geometry::RegisteredWindow {
3622            key: entry.key.clone(),
3623            live: true,
3624            title: entry.title.clone(),
3625            pid: entry.pid,
3626        },
3627    )
3628}
3629
3630/// Renders a [`geometry::RepositionReport`] as the op's reply.
3631///
3632/// `trusted` is a reply **field**, not an error, so the client can branch on the
3633/// missing-permission case as data — offering the user a link to the Accessibility
3634/// settings pane — rather than pattern-matching an error string.
3635fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3636    let mut reply = json!({
3637        "trusted": report.trusted,
3638        "results": report.results,
3639        "moved": report.moved(),
3640        "skipped": report.skipped(),
3641    });
3642    if let Some(reference) = &report.reference {
3643        reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3644    }
3645    if let Some(blocked) = &report.blocked {
3646        reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3647    }
3648    // Omitted unless true, so a client that only reads `results` sees a reply
3649    // byte-identical to one from a daemon without the undo store.
3650    if undoable {
3651        reply["undoable"] = Value::Bool(true);
3652    }
3653    reply
3654}
3655
3656/// Emits the audit line for a `reposition`, so `omni-dev daemon logs` can answer
3657/// "why did that window not move?" from the log alone (the ADR-0049 §6 precedent).
3658/// Sync, like [`log_merge_check`].
3659fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3660    // `phase` is a structured field rather than three message literals, so a log
3661    // filter can select checks from applies without matching on prose.
3662    let phase = if !report.trusted {
3663        "untrusted"
3664    } else if report.blocked.is_some() {
3665        "blocked"
3666    } else if req.check {
3667        "check"
3668    } else {
3669        "apply"
3670    };
3671    tracing::info!(
3672        phase,
3673        reference = req.reference_key.as_str(),
3674        requested = req.target_keys.len(),
3675        blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3676        moved = report.moved(),
3677        skipped = report.skipped(),
3678        outcomes = outcome_kinds(report).as_str(),
3679        "reposition"
3680    );
3681}
3682
3683/// Emits the audit line for a `reposition-undo`.
3684fn log_reposition_undo(report: &geometry::RepositionReport) {
3685    tracing::info!(
3686        trusted = report.trusted,
3687        restored = report.moved(),
3688        skipped = report.skipped(),
3689        outcomes = outcome_kinds(report).as_str(),
3690        "reposition undo"
3691    );
3692}
3693
3694/// Joins a report's per-target outcome slugs into one compact `a,b,b` field, so a
3695/// batch's verdict rides a single structured log value rather than a `Debug` dump —
3696/// the [`note_kinds`] precedent.
3697fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3698    if report.results.is_empty() {
3699        return "-".to_string();
3700    }
3701    report
3702        .results
3703        .iter()
3704        .map(|r| r.outcome)
3705        .collect::<Vec<_>>()
3706        .join(",")
3707}
3708
3709// --- Reload op (#1417) -------------------------------------------------------
3710
3711/// The `reload` op payload: reload the listed windows.
3712///
3713/// Keyed by **window**, like [`RepositionRequest`] and unlike [`CloseRequest`] —
3714/// a reload acts on a window, and one tree row is one window, whereas a path can
3715/// be open in several. There is no `requester_key`: a client that wants to
3716/// reload itself does so directly rather than waiting a heartbeat for its own
3717/// directive, so the daemon never needs to know who asked.
3718#[derive(Debug, Clone, Deserialize)]
3719struct ReloadRequest {
3720    /// Registry keys of the windows to signal. An empty list is a no-op, not an
3721    /// error: the callers all filter their targets first, and reporting zeros is
3722    /// more useful to a batch client than a failure.
3723    #[serde(default)]
3724    target_keys: Vec<String>,
3725}
3726
3727/// Emits the audit line for a `reload` op. Sync, like the `close` loggers, so it
3728/// is unit-testable off the runtime. Logs counts and the unknown keys only —
3729/// never a path, which this op never sees.
3730fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3731    // Formatted before the macro, not inside it: a `tracing` field expression is
3732    // only evaluated when a subscriber is interested, so inlining this would
3733    // leave it unexecuted (and unmeasurable) in any test that installs none.
3734    let unknown = if unknown.is_empty() {
3735        "-".to_string()
3736    } else {
3737        unknown.join(",")
3738    };
3739    tracing::info!(
3740        requested,
3741        signalled,
3742        unknown = %unknown,
3743        "worktrees reload: signalled windows"
3744    );
3745}
3746
3747// --- Close op (#1277) --------------------------------------------------------
3748
3749/// The `close` op payload: close a worktree's window and (for a linked worktree)
3750/// delete it. Symmetric to `open`, but destructive, so it carries the
3751/// two-phase-confirm and self-close routing fields.
3752#[derive(Debug, Clone, Deserialize)]
3753struct CloseRequest {
3754    /// Absolute path of the target worktree's working directory.
3755    path: PathBuf,
3756    /// The requesting window's key, so a self-close (`requester_key` owns the
3757    /// target) removes-then-replies and lets the extension close its own window,
3758    /// rather than waiting on a window that is blocked awaiting this reply.
3759    #[serde(default)]
3760    requester_key: Option<String>,
3761    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
3762    /// only close its window (main "Close Window"). A delete is refused on the
3763    /// main working tree regardless of this flag.
3764    #[serde(default)]
3765    remove: bool,
3766    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
3767    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
3768    #[serde(default)]
3769    confirmed: bool,
3770}
3771
3772/// One risk or informational note in a [`SafetyReport`]: a machine-readable
3773/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
3774/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
3775/// commits that survive because the branch is kept).
3776#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3777struct Note {
3778    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
3779    kind: String,
3780    /// A human-readable one-line explanation for the confirm dialog.
3781    detail: String,
3782}
3783
3784impl Note {
3785    fn new(kind: &str, detail: impl Into<String>) -> Self {
3786        Self {
3787            kind: kind.to_string(),
3788            detail: detail.into(),
3789        }
3790    }
3791}
3792
3793/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
3794/// `-`) for a single structured log field. Used by the `close` op's audit lines
3795/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
3796fn note_kinds(notes: &[Note]) -> String {
3797    if notes.is_empty() {
3798        return "-".to_string();
3799    }
3800    notes
3801        .iter()
3802        .map(|n| n.kind.as_str())
3803        .collect::<Vec<_>>()
3804        .join(",")
3805}
3806
3807/// Whether a `close` execute is a **self-close**: the requesting window owns the
3808/// target, so it acts on our `ok:true` reply and never rides the cross-window
3809/// signal. Split out as a pure predicate so the routing decision the audit line
3810/// (#1364) reports is unit-testable.
3811fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3812    requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3813}
3814
3815/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
3816/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
3817/// this a failed safety check (a non-git-worktree target) or a panicked blocking
3818/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
3819/// the error unchanged so callers keep using `?`.
3820fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3821    tracing::error!(
3822        path = %path.display(),
3823        "worktrees close: {phase} failed: {err:#}"
3824    );
3825    err
3826}
3827
3828/// Logs the outcome of a linked-worktree removal and maps it to the `close`
3829/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
3830/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
3831/// the real prune runs behind.
3832///
3833/// The three outcomes are logged distinctly (#1403) so a future "the daemon says
3834/// pruned but the row is still there" is diagnosable from the log alone: an
3835/// actual prune and an already-gone no-op are both INFO (and both reply
3836/// `removed: true` — the row should go either way), but carry different
3837/// `outcome`/message text; a failure is WARN and propagates the error.
3838fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3839    match removed {
3840        Ok(Removal::Pruned) => {
3841            tracing::info!(
3842                path = %path.display(),
3843                outcome = "pruned",
3844                "worktrees close: linked worktree pruned"
3845            );
3846            Ok(json!({ "removed": true }))
3847        }
3848        Ok(Removal::AlreadyGone) => {
3849            tracing::info!(
3850                path = %path.display(),
3851                outcome = "already-gone",
3852                "worktrees close: nothing to prune, worktree already removed"
3853            );
3854            Ok(json!({ "removed": true }))
3855        }
3856        Err(err) => {
3857            tracing::warn!(
3858                path = %path.display(),
3859                outcome = "failed",
3860                "worktrees close: worktree prune failed: {err:#}"
3861            );
3862            Err(err)
3863        }
3864    }
3865}
3866
3867/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
3868/// the owning window key (if any), the open flag, and the deletability verdict
3869/// with the blocking risk kinds. Split out so the audit line is unit-testable off
3870/// the runtime — a `tracing` event fired right after the `git_safety`
3871/// `spawn_blocking` is not reliably captured under the parallel suite.
3872fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3873    tracing::info!(
3874        path = %path.display(),
3875        window_key = window_key.unwrap_or("-"),
3876        removable = git.removable,
3877        is_main = git.is_main,
3878        open,
3879        risks = %note_kinds(&git.risks),
3880        "worktrees close: safety check"
3881    );
3882}
3883
3884/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
3885/// window key and the routing decision (self-close vs. how many cross-window
3886/// targets are being signalled), logged before the wait so it is auditable even
3887/// if that wait then hangs. Sync so it is unit-testable off the runtime.
3888fn log_executing(
3889    path: &Path,
3890    requester: Option<&str>,
3891    remove: bool,
3892    self_close: bool,
3893    cross_window: usize,
3894) {
3895    tracing::info!(
3896        path = %path.display(),
3897        requester = requester.unwrap_or("-"),
3898        remove,
3899        self_close,
3900        cross_window,
3901        "worktrees close: executing"
3902    );
3903}
3904
3905/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
3906/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
3907/// unit-testable off the runtime.
3908fn log_close_abort(path: &Path, err: &anyhow::Error) {
3909    tracing::warn!(
3910        path = %path.display(),
3911        "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3912    );
3913}
3914
3915/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
3916/// the window is closed and nothing is deleted (#1364). Sync so it is
3917/// unit-testable off the runtime.
3918fn log_window_closed(path: &Path) {
3919    tracing::info!(
3920        path = %path.display(),
3921        "worktrees close: window closed, no removal"
3922    );
3923}
3924
3925/// The phase-1 safety report the extension reads to decide whether to prompt.
3926/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
3927/// entry → show a modal confirm listing them.
3928#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3929struct SafetyReport {
3930    /// Whether the target is a deletable (linked) worktree at all — `false` for
3931    /// the main working tree, which the daemon never removes.
3932    removable: bool,
3933    /// Whether the target is the repository's main working tree.
3934    is_main: bool,
3935    /// Whether a live VS Code window currently has the target open.
3936    open: bool,
3937    /// The owning window's key, when `open` (the first, for the wait/close).
3938    #[serde(skip_serializing_if = "Option::is_none")]
3939    window_key: Option<String>,
3940    /// How many workspace folders the owning window has — so the extension can
3941    /// warn "this window has N folders open; all will close" (failure mode #10).
3942    window_folder_count: usize,
3943    /// Conditions that would lose data on removal; a non-empty list forces a
3944    /// confirm dialog.
3945    risks: Vec<Note>,
3946    /// Non-blocking context shown for awareness (e.g. unpushed commits that
3947    /// survive because the branch is kept).
3948    info: Vec<Note>,
3949}
3950
3951/// The git-only half of the safety check, before the registry's open-window
3952/// facts are folded in. Pure disk I/O; computed on a blocking thread.
3953#[derive(Debug, Clone, PartialEq, Eq)]
3954struct GitSafety {
3955    is_main: bool,
3956    removable: bool,
3957    risks: Vec<Note>,
3958    info: Vec<Note>,
3959}
3960
3961// --- Rebase op (#1415) -------------------------------------------------------
3962
3963/// The `rebase` op payload: batch-rebase worktrees onto their repository's remote
3964/// default branch. Two-phase like [`MergeQueueRequest`], keyed off `confirmed`,
3965/// and likewise a **single batched** op over `paths` — which is what buys the
3966/// fetch-once-per-repository contract (ADR-0055 §2), since the engine can only
3967/// group by repository if it sees the whole selection at once.
3968#[derive(Debug, Clone, Deserialize)]
3969struct RebaseRequest {
3970    /// Absolute paths of the selected worktree folders.
3971    paths: Vec<PathBuf>,
3972    /// The requesting window's key — carried for the audit line, as `close` and
3973    /// `merge-queue` carry theirs.
3974    #[serde(default)]
3975    requester_key: Option<String>,
3976    /// Phase 1: plan and report only, never rebase.
3977    #[serde(default)]
3978    check: bool,
3979    /// Phase 2: rebase the (re-validated) pending worktrees.
3980    #[serde(default)]
3981    confirmed: bool,
3982    /// Leave a conflicting worktree mid-rebase instead of aborting it. The tree
3983    /// view sends `true` — resolving a conflict in place is the point of #1415 —
3984    /// but it stays a client choice, and defaults to the engine's conservative
3985    /// abort so an older or scripted client gets the pre-#1415 behaviour.
3986    #[serde(default)]
3987    keep_conflicts: bool,
3988    /// Stash uncommitted changes around each rebase rather than skipping a dirty
3989    /// worktree. Not surfaced by the tree view; here so a socket client can ask.
3990    #[serde(default)]
3991    autostash: bool,
3992    /// Rebase onto this ref instead of the remote default branch.
3993    #[serde(default)]
3994    onto: Option<String>,
3995}
3996
3997impl RebaseRequest {
3998    /// The engine options this request selects, with `git` already resolved.
3999    fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4000        worktree_rebase::RebaseOptions {
4001            onto: self.onto.clone(),
4002            autostash: self.autostash,
4003            // The daemon never uses the engine's own dry-run flag: phase 1 *is*
4004            // the dry run, and it is `plan` (never `execute`) that runs for it.
4005            dry_run: false,
4006            keep_conflicts: self.keep_conflicts,
4007            git_bin: Some(git_bin),
4008        }
4009    }
4010}
4011
4012/// Runs [`worktree_rebase::plan`] on a blocking thread: it shells out to
4013/// `git fetch` once per repository and walks each worktree's object database, so
4014/// it must never run on an async worker.
4015async fn plan_rebase(
4016    selection: &Selection,
4017    opts: &worktree_rebase::RebaseOptions,
4018) -> Result<worktree_rebase::Plan> {
4019    let selection = selection.clone();
4020    let opts = opts.clone();
4021    tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4022        .await
4023        .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4024        .and_then(|inner| inner)
4025}
4026
4027/// Builds a `rebase` reply. Both phases share one shape — the per-repo fetch
4028/// outcomes and the per-worktree results — because phase 1's report and phase 2's
4029/// result differ only in which [`RebaseResult`](worktree_rebase::RebaseResult)
4030/// variants appear, and a client that can render one can render the other.
4031fn rebase_reply(
4032    fetches: &[worktree_rebase::FetchOutcome],
4033    worktrees: &[worktree_rebase::WorktreeOutcome],
4034) -> Value {
4035    json!({ "fetches": fetches, "worktrees": worktrees })
4036}
4037
4038/// Emits the phase-1 audit line for a `rebase` plan (ADR-0049 §6's precedent, as
4039/// applied by [`log_merge_check`]): who asked, how many worktrees were named, and
4040/// how many the classifier found actually pending.
4041fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4042    let pending = plan
4043        .worktrees
4044        .iter()
4045        .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4046        .count();
4047    let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4048    tracing::info!(
4049        requester = req.requester_key.as_deref().unwrap_or("-"),
4050        requested = req.paths.len(),
4051        pending,
4052        fetches = plan.fetches.len(),
4053        failed_fetches,
4054        "rebase check"
4055    );
4056}
4057
4058/// Emits the phase-2 audit line for a `rebase` execute: the history-rewriting
4059/// outcome, counted by kind. A left-in-place conflict is counted separately from
4060/// an aborted one — it is the case that leaves a worktree needing the user.
4061fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4062    use worktree_rebase::RebaseResult;
4063    let mut rebased = 0;
4064    let mut conflicts = 0;
4065    let mut left_in_place = 0;
4066    let mut skipped = 0;
4067    for outcome in outcomes {
4068        match &outcome.result {
4069            RebaseResult::Rebased { .. } => rebased += 1,
4070            RebaseResult::Conflict {
4071                left_in_place: k, ..
4072            } => {
4073                conflicts += 1;
4074                if *k {
4075                    left_in_place += 1;
4076                }
4077            }
4078            RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4079            RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4080        }
4081    }
4082    tracing::info!(
4083        requester = req.requester_key.as_deref().unwrap_or("-"),
4084        requested = req.paths.len(),
4085        rebased,
4086        conflicts,
4087        left_in_place,
4088        skipped,
4089        "rebase execute"
4090    );
4091}
4092
4093// --- Push op (#1443) ---------------------------------------------------------
4094
4095/// The `push` op payload: publish worktrees' branches to their upstreams,
4096/// force-pushing with a lease where history was rewritten. Two-phase like
4097/// [`RebaseRequest`], keyed off `confirmed`, and likewise a **single batched** op
4098/// over `paths` so the reply is one per-worktree summary rather than N independent
4099/// results.
4100///
4101/// Deliberately has **no** force knob. There is no field a client can set to escape
4102/// the lease, and none to reach a remote other than the branch's own upstream —
4103/// both by design (ADR-0061 §2).
4104#[derive(Debug, Clone, Deserialize)]
4105struct PushRequest {
4106    /// Absolute paths of the selected worktree folders.
4107    paths: Vec<PathBuf>,
4108    /// The requesting window's key — carried for the audit line, as `close`,
4109    /// `merge-queue` and `rebase` carry theirs.
4110    #[serde(default)]
4111    requester_key: Option<String>,
4112    /// Phase 1: classify and report only, never push.
4113    #[serde(default)]
4114    check: bool,
4115    /// Phase 2: publish the (re-validated) pending worktrees.
4116    #[serde(default)]
4117    confirmed: bool,
4118}
4119
4120/// Runs [`worktree_push::plan`] on a blocking thread: it walks each worktree's
4121/// object database, so it must never run on an async worker. Unlike
4122/// [`plan_rebase`] it needs no `git` binary — planning a push contacts no remote.
4123async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4124    let selection = selection.clone();
4125    tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4126        .await
4127        .map_err(|e| anyhow!("push planning task panicked: {e}"))
4128        .and_then(|inner| inner)
4129}
4130
4131/// Builds a `push` reply. Both phases share one shape — the per-worktree results —
4132/// because phase 1's report and phase 2's result differ only in which
4133/// [`PushResult`](worktree_push::PushResult) variants appear, and a client that can
4134/// render one can render the other.
4135///
4136/// There is no `fetches` field (the one shape difference from `rebase`): a push
4137/// plan contacts no remote, so there is nothing per-repository to report.
4138fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4139    json!({ "worktrees": worktrees })
4140}
4141
4142/// Emits the phase-1 audit line for a `push` plan: who asked, how many worktrees
4143/// were named, how many are pending, and — separately — how many would need the
4144/// lease, since that is the interesting half.
4145fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4146    use worktree_push::PushResult;
4147    let pending = plan
4148        .worktrees
4149        .iter()
4150        .filter(|w| w.result.is_pending())
4151        .count();
4152    let forced = plan
4153        .worktrees
4154        .iter()
4155        .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4156        .count();
4157    let skipped = plan
4158        .worktrees
4159        .iter()
4160        .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4161        .count();
4162    tracing::info!(
4163        requester = req.requester_key.as_deref().unwrap_or("-"),
4164        requested = req.paths.len(),
4165        pending,
4166        forced,
4167        skipped,
4168        "push check"
4169    );
4170}
4171
4172/// Emits the phase-2 audit line for a `push` execute. `forced` and `stale_rejected`
4173/// are broken out deliberately: the first is the count of histories this daemon
4174/// published a rewrite of, and the second the count of times the lease stopped it
4175/// from overwriting work it had not seen.
4176fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4177    use worktree_push::PushResult;
4178    let mut pushed = 0;
4179    let mut forced = 0;
4180    let mut created = 0;
4181    let mut rejected = 0;
4182    let mut stale_rejected = 0;
4183    for outcome in outcomes {
4184        match &outcome.result {
4185            PushResult::Pushed { forced: f } => {
4186                pushed += 1;
4187                if *f {
4188                    forced += 1;
4189                }
4190            }
4191            PushResult::Created => created += 1,
4192            PushResult::Rejected { stale, .. } => {
4193                rejected += 1;
4194                if *stale {
4195                    stale_rejected += 1;
4196                }
4197            }
4198            PushResult::UpToDate
4199            | PushResult::WouldFastForward { .. }
4200            | PushResult::WouldForce { .. }
4201            | PushResult::WouldCreate
4202            | PushResult::Skipped { .. } => {}
4203        }
4204    }
4205    tracing::info!(
4206        requester = req.requester_key.as_deref().unwrap_or("-"),
4207        requested = req.paths.len(),
4208        pushed,
4209        forced,
4210        created,
4211        rejected,
4212        stale_rejected,
4213        "push execute"
4214    );
4215}
4216
4217// --- Merge-queue op (#1401) --------------------------------------------------
4218
4219/// The `merge-queue` op payload: batch-enqueue eligible worktrees' PRs into the
4220/// GitHub merge queue. Two-phase like [`CloseRequest`], keyed off `confirmed`, but
4221/// a **single batched** op over `paths` rather than one op per target.
4222#[derive(Debug, Clone, Deserialize)]
4223struct MergeQueueRequest {
4224    /// Absolute paths of the selected worktree folders.
4225    paths: Vec<PathBuf>,
4226    /// The requesting window's key — carried for parity with `close` and future
4227    /// per-window routing; unused today.
4228    #[serde(default)]
4229    requester_key: Option<String>,
4230    /// Phase 1: report eligibility only, never enqueue.
4231    #[serde(default)]
4232    check: bool,
4233    /// Phase 2: enqueue the (re-validated) eligible PRs.
4234    #[serde(default)]
4235    confirmed: bool,
4236}
4237
4238/// One enqueue-eligible worktree in an [`EligibilityReport`].
4239#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4240struct PrRef {
4241    /// The worktree folder.
4242    path: String,
4243    /// The open PR number.
4244    number: u64,
4245    /// The PR's web URL.
4246    url: String,
4247    /// The branch the PR heads.
4248    branch: String,
4249}
4250
4251impl From<&Eligible> for PrRef {
4252    fn from(e: &Eligible) -> Self {
4253        Self {
4254            path: e.path.to_string_lossy().to_string(),
4255            number: e.number,
4256            url: e.url.clone(),
4257            branch: e.branch.clone(),
4258        }
4259    }
4260}
4261
4262/// One skipped worktree: which, and why — a machine `kind` slug plus a
4263/// human-readable `detail`, mirroring [`Note`].
4264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4265struct Skip {
4266    path: String,
4267    kind: String,
4268    detail: String,
4269}
4270
4271impl Skip {
4272    fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4273        Self {
4274            path: path.to_string_lossy().to_string(),
4275            kind: kind.to_string(),
4276            detail: detail.into(),
4277        }
4278    }
4279}
4280
4281/// The phase-1 reply: which selected worktrees are enqueue-eligible and which are
4282/// skipped-with-reason. The extension confirms once over the whole set, then sends
4283/// the phase-2 execute.
4284#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4285struct EligibilityReport {
4286    eligible: Vec<PrRef>,
4287    skipped: Vec<Skip>,
4288}
4289
4290/// One PR successfully in the queue after phase 2.
4291#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4292struct QueuedPr {
4293    path: String,
4294    number: u64,
4295    /// True when the PR was already in the queue — an idempotent no-op, reported as
4296    /// success. Omitted (false) on the wire for the common freshly-queued case.
4297    #[serde(skip_serializing_if = "is_false")]
4298    already_queued: bool,
4299}
4300
4301/// One PR the enqueue mutation rejected (merge queue disabled, not mergeable,
4302/// insufficient permissions, …).
4303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4304struct EnqueueFailure {
4305    path: String,
4306    number: u64,
4307    error: String,
4308}
4309
4310/// The phase-2 reply: the enqueue outcome for the selected worktrees. `skipped` is
4311/// the re-validated skip set (a worktree that became ineligible between phases).
4312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4313struct EnqueueResult {
4314    queued: Vec<QueuedPr>,
4315    skipped: Vec<Skip>,
4316    failed: Vec<EnqueueFailure>,
4317}
4318
4319/// A worktree that cleared the local (git-only) gates 1–3, carrying what the
4320/// network step needs to resolve its PR.
4321#[derive(Debug)]
4322struct LocalOk {
4323    path: PathBuf,
4324    target: PrTarget,
4325    head_sha: String,
4326}
4327
4328/// A worktree that cleared **every** gate and is ready to enqueue.
4329#[derive(Debug)]
4330struct Eligible {
4331    path: PathBuf,
4332    number: u64,
4333    url: String,
4334    branch: String,
4335    /// The PR's GraphQL node id — the `enqueuePullRequest` input.
4336    pr_id: String,
4337    /// Already in the queue ⇒ phase 2 skips the mutation and reports success.
4338    already_queued: bool,
4339}
4340
4341/// Evaluates the **local** (git-only) merge-queue gates for one worktree — clean
4342/// tree (1), a real commit (2), fully pushed (3) — and resolves the branch's
4343/// [`PrTarget`] for the network step. Pure disk I/O; runs on a blocking thread.
4344/// Returns the first failing gate as a [`Skip`] so an ineligible worktree never
4345/// costs a GitHub call.
4346fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4347    let Ok(repo) = Repository::discover(path) else {
4348        return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4349    };
4350    // Gate 1: a clean working tree (reusing the `close` safety check's counter).
4351    let (dirty, untracked) = count_dirty_untracked(&repo);
4352    if dirty > 0 {
4353        return Err(Skip::new(
4354            path,
4355            "dirty",
4356            format!("{dirty} modified tracked file(s) — commit or stash first"),
4357        ));
4358    }
4359    if untracked > 0 {
4360        return Err(Skip::new(
4361            path,
4362            "untracked",
4363            format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4364        ));
4365    }
4366    // Gate 2: a real commit exists (a non-unborn HEAD). The deeper "commits beyond
4367    // base" is proven by the open PR (gate 4) + GitHub's own enqueue validation.
4368    let Ok(head) = repo.head() else {
4369        return Err(Skip::new(
4370            path,
4371            "no-commits",
4372            "the branch has no commits yet",
4373        ));
4374    };
4375    let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4376        return Err(Skip::new(
4377            path,
4378            "no-commits",
4379            "HEAD does not resolve to a commit",
4380        ));
4381    };
4382    // A branch HEAD has a UTF-8 shorthand; a detached HEAD has no branch — and so
4383    // no branch PR to enqueue. Read before `Branch::wrap` consumes `head`.
4384    let Some(branch_name) = head
4385        .shorthand()
4386        .ok()
4387        .filter(|_| head.is_branch())
4388        .map(str::to_string)
4389    else {
4390        return Err(Skip::new(
4391            path,
4392            "detached",
4393            "HEAD is detached — no branch to enqueue",
4394        ));
4395    };
4396    let branch = git2::Branch::wrap(head);
4397    // Gate 3: fully pushed — an upstream exists and matches the local head.
4398    let Some(upstream_sha) = upstream_target(&branch) else {
4399        return Err(Skip::new(
4400            path,
4401            "no-upstream",
4402            "the branch tracks no upstream — push it first",
4403        ));
4404    };
4405    if upstream_sha != head_sha {
4406        return Err(Skip::new(
4407            path,
4408            "unpushed",
4409            "local commits are not on the remote yet — push first",
4410        ));
4411    }
4412    // Belt-and-suspenders: even with matching heads, a positive ahead count is
4413    // unpushed work.
4414    if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4415        if ahead > 0 {
4416            return Err(Skip::new(
4417                path,
4418                "unpushed",
4419                format!("{ahead} unpushed commit(s) — push first"),
4420            ));
4421        }
4422    }
4423    // Gate 4 setup: the branch's GitHub identity, so the network step can resolve
4424    // its PR. A non-github repo can never have a merge-queue PR.
4425    let Some(id) = remote_github_identity(&repo) else {
4426        return Err(Skip::new(
4427            path,
4428            "no-github",
4429            "the repository has no github.com remote",
4430        ));
4431    };
4432    Ok(LocalOk {
4433        path: path.to_path_buf(),
4434        target: PrTarget {
4435            owner: id.owner,
4436            name: id.name,
4437            branch: branch_name,
4438        },
4439        head_sha,
4440    })
4441}
4442
4443/// Whether GitHub's `mergeStateStatus` says the PR cannot merge cleanly. `DIRTY`
4444/// (merge conflicts) and the explicit `CONFLICTING` both block enqueue; other
4445/// states (`BLOCKED` on a required review, `UNKNOWN` still computing, `CLEAN`) do
4446/// not, since the merge queue itself resolves them.
4447fn is_conflicting(state: Option<&str>) -> bool {
4448    matches!(state, Some("CONFLICTING" | "DIRTY"))
4449}
4450
4451/// A human-readable label for a rolled-up CI verdict, for a `checks-failing` skip.
4452fn check_label(state: PrCheckState) -> &'static str {
4453    match state {
4454        PrCheckState::Success => "passing",
4455        PrCheckState::Failure => "failing",
4456        PrCheckState::Pending => "still running",
4457        PrCheckState::None => "not reported",
4458    }
4459}
4460
4461/// Emits the phase-1 audit line for a `merge-queue` check (ADR-0056; the ADR-0049
4462/// §6 precedent): the requesting window key, how many worktrees were requested,
4463/// and the eligible/skipped split. Sync so it is unit-testable off the runtime —
4464/// and so its `tracing` field expressions are exercised under an INFO subscriber.
4465fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4466    tracing::info!(
4467        requester = req.requester_key.as_deref().unwrap_or("-"),
4468        requested = req.paths.len(),
4469        eligible,
4470        skipped,
4471        "merge-queue check"
4472    );
4473}
4474
4475/// Emits the phase-2 audit line for a `merge-queue` enqueue: the requesting window
4476/// key and the queued/failed/skipped counts. Sync, for the same reasons as
4477/// [`log_merge_check`].
4478fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4479    tracing::info!(
4480        requester = req.requester_key.as_deref().unwrap_or("-"),
4481        queued,
4482        failed,
4483        skipped,
4484        "merge-queue enqueue"
4485    );
4486}
4487
4488/// Evaluates every merge-queue gate for a batch of worktree paths and partitions
4489/// them into the enqueue-eligible and the skipped-with-reason. **Blocking** — run
4490/// on a blocking thread.
4491///
4492/// Local gates 1–3 run first (per path); only survivors reach GitHub, so a dirty
4493/// or unpushed worktree is skipped with **zero** API calls. The survivors' PRs are
4494/// resolved in **one** batched `gh api graphql` call, then the network gates —
4495/// an open PR (4), not a draft (5), not conflicting (6), CI green (7), and the
4496/// remote head matching the local head — are applied. Shared by both phases: phase
4497/// 2 re-runs it (never trusting a phase-1 result the client sent).
4498fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4499    let mut skipped = Vec::new();
4500    let mut locals = Vec::new();
4501    for path in paths {
4502        match evaluate_local(path) {
4503            Ok(ok) => locals.push(ok),
4504            Err(skip) => skipped.push(skip),
4505        }
4506    }
4507    if locals.is_empty() {
4508        return Ok((Vec::new(), skipped));
4509    }
4510    let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4511    let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4512    let mut eligible = Vec::new();
4513    for local in locals {
4514        let Some(info) = resolved.get(&local.target) else {
4515            skipped.push(Skip::new(
4516                &local.path,
4517                "no-pr",
4518                "no open PR heads this branch",
4519            ));
4520            continue;
4521        };
4522        if info.head_oid != local.head_sha {
4523            skipped.push(Skip::new(
4524                &local.path,
4525                "stale",
4526                "the open PR's head differs from the local head — re-check",
4527            ));
4528        } else if info.is_draft {
4529            skipped.push(Skip::new(
4530                &local.path,
4531                "draft",
4532                format!("PR #{} is a draft", info.number),
4533            ));
4534        } else if is_conflicting(info.merge_state.as_deref()) {
4535            skipped.push(Skip::new(
4536                &local.path,
4537                "conflicting",
4538                format!("PR #{} has merge conflicts", info.number),
4539            ));
4540        } else if info.checks != PrCheckState::Success {
4541            skipped.push(Skip::new(
4542                &local.path,
4543                "checks-failing",
4544                format!(
4545                    "PR #{} checks are {}",
4546                    info.number,
4547                    check_label(info.checks)
4548                ),
4549            ));
4550        } else {
4551            eligible.push(Eligible {
4552                path: local.path,
4553                number: info.number,
4554                url: info.url.clone(),
4555                branch: local.target.branch.clone(),
4556                pr_id: info.pr_id.clone(),
4557                already_queued: info.already_queued,
4558            });
4559        }
4560    }
4561    Ok((eligible, skipped))
4562}
4563
4564/// Enqueues each eligible PR into its repo's merge queue, sequentially.
4565/// **Blocking** — run on a blocking thread. An already-queued PR is reported as
4566/// success without a mutation; a GitHub rejection or a failed `gh` invocation
4567/// lands in `failed[]`, so one un-enqueuable PR never sinks the batch.
4568fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4569    let mut queued = Vec::new();
4570    let mut failed = Vec::new();
4571    for e in eligible {
4572        let path = e.path.to_string_lossy().to_string();
4573        if e.already_queued {
4574            queued.push(QueuedPr {
4575                path,
4576                number: e.number,
4577                already_queued: true,
4578            });
4579            continue;
4580        }
4581        match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4582            Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4583                path,
4584                number: e.number,
4585                already_queued: false,
4586            }),
4587            Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4588                path,
4589                number: e.number,
4590                error: msg,
4591            }),
4592            Err(err) => failed.push(EnqueueFailure {
4593                path,
4594                number: e.number,
4595                error: format!("{err:#}"),
4596            }),
4597        }
4598    }
4599    (queued, failed)
4600}
4601
4602/// Live windows (key, workspace-folder count) that currently have `path` open,
4603/// matched by canonicalized path so a symlinked or `..`-laden report still
4604/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
4605fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4606    let target = canonical(path);
4607    entries
4608        .iter()
4609        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4610        .map(|e| (e.key.clone(), e.folders.len()))
4611        .collect()
4612}
4613
4614/// How long the execute phase waits for a signalled window to close
4615/// (`unregister`) before giving up. Deliberately generous against the ~10s
4616/// heartbeat interval the close directive rides — a window may have just
4617/// heartbeated, so the directive is only picked up on the *next* one — plus the
4618/// window's own close/save latency. The keyed-push responsiveness upgrade
4619/// (#1277 fast-follow) removes this wait entirely.
4620const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4621
4622/// How often the execute phase re-checks whether the signalled windows have
4623/// unregistered.
4624const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4625
4626/// Waits up to `timeout` for every window *other than* `requester` that has
4627/// `path` open to unregister (close), polling the live registry every `poll`.
4628/// A window whose `last_seen` has already gone stale is reaped by `list()` and
4629/// so counts as closed. Returns an error naming the still-open windows on
4630/// timeout, so the caller can surface "window did not close" and leave the
4631/// worktree untouched (failure modes #4/#5).
4632async fn await_windows_closed(
4633    registry: &WorktreesRegistry,
4634    path: &Path,
4635    requester: Option<&str>,
4636    timeout: Duration,
4637    poll: Duration,
4638) -> Result<()> {
4639    let deadline = std::time::Instant::now() + timeout;
4640    loop {
4641        // The registry read is cheap CPU, but the path canonicalization in
4642        // `windows_with_path` is disk I/O — do the whole check on a blocking
4643        // thread, never on the async worker.
4644        let entries = registry.list();
4645        let path = path.to_path_buf();
4646        let requester = requester.map(str::to_string);
4647        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4648            windows_with_path(&entries, &path)
4649                .into_iter()
4650                .map(|(k, _)| k)
4651                .filter(|k| requester.as_deref() != Some(k))
4652                .collect()
4653        })
4654        .await
4655        .unwrap_or_default();
4656
4657        if remaining.is_empty() {
4658            return Ok(());
4659        }
4660        if std::time::Instant::now() >= deadline {
4661            bail!("window(s) did not close in time: {}", remaining.join(", "));
4662        }
4663        tokio::time::sleep(poll).await;
4664    }
4665}
4666
4667/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
4668/// working tree (never removable) and, for a linked worktree, what a removal
4669/// would lose. Best-effort per-check but the overall open must succeed — a path
4670/// that is not a git worktree is a hard error (we refuse to delete an unknown
4671/// directory). A path that no longer exists is treated as an already-removed
4672/// linked worktree so the idempotent execute path can proceed with no dialog.
4673fn git_safety(path: &Path) -> Result<GitSafety> {
4674    if !path.exists() {
4675        return Ok(GitSafety {
4676            is_main: false,
4677            removable: true,
4678            risks: vec![],
4679            info: vec![Note::new("already-removed", "worktree no longer exists")],
4680        });
4681    }
4682    let repo = Repository::open(path)
4683        .with_context(|| format!("not a git worktree: {}", path.display()))?;
4684    // The one structural fact deletability keys off — never the branch name.
4685    if !repo.is_worktree() {
4686        return Ok(GitSafety {
4687            is_main: true,
4688            removable: false,
4689            risks: vec![],
4690            info: vec![Note::new(
4691                "main-working-tree",
4692                "the repository's main working tree is never deleted",
4693            )],
4694        });
4695    }
4696
4697    let mut risks = Vec::new();
4698    let mut info = Vec::new();
4699
4700    let (dirty, untracked) = count_dirty_untracked(&repo);
4701    if dirty > 0 {
4702        risks.push(Note::new(
4703            "dirty",
4704            format!("{dirty} modified tracked file(s) would be lost"),
4705        ));
4706    }
4707    if untracked > 0 {
4708        risks.push(Note::new(
4709            "untracked",
4710            format!("{untracked} untracked file(s) would be lost"),
4711        ));
4712    }
4713
4714    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
4715    let state = repo.state();
4716    if state != RepositoryState::Clean {
4717        risks.push(Note::new(
4718            "in-progress",
4719            format!("an in-progress {state:?} operation would be lost"),
4720        ));
4721    }
4722
4723    // Commits reachable only from a detached HEAD are GC'd once the worktree —
4724    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
4725    // or tag) loses nothing, so it is not flagged.
4726    if repo.head_detached().unwrap_or(false) {
4727        let lost = unreachable_commit_count(&repo).unwrap_or(0);
4728        if lost > 0 {
4729            risks.push(Note::new(
4730                "unreachable-commits",
4731                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4732            ));
4733        }
4734    }
4735
4736    // Unpushed commits on a *named* branch survive: removal never deletes the
4737    // branch. Informational only — it must not block or prompt.
4738    if let Some(ahead) = current_branch_ahead(&repo) {
4739        if ahead > 0 {
4740            info.push(Note::new(
4741                "unpushed",
4742                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4743            ));
4744        }
4745    }
4746
4747    Ok(GitSafety {
4748        is_main: false,
4749        removable: true,
4750        risks,
4751        info,
4752    })
4753}
4754
4755/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
4756/// staged or unstaged modification (including conflicts and deletions);
4757/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
4758/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
4759/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
4760/// `(0, 0)` rather than sinking the whole safety check.
4761fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4762    let mut opts = StatusOptions::new();
4763    opts.include_untracked(true)
4764        .recurse_untracked_dirs(true)
4765        .include_ignored(false)
4766        .exclude_submodules(true);
4767    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4768        return (0, 0);
4769    };
4770    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
4771    // it is deliberately excluded from this mask).
4772    let tracked = Status::INDEX_NEW
4773        | Status::INDEX_MODIFIED
4774        | Status::INDEX_DELETED
4775        | Status::INDEX_RENAMED
4776        | Status::INDEX_TYPECHANGE
4777        | Status::WT_MODIFIED
4778        | Status::WT_DELETED
4779        | Status::WT_TYPECHANGE
4780        | Status::WT_RENAMED
4781        | Status::CONFLICTED;
4782    let mut dirty = 0;
4783    let mut untracked = 0;
4784    for entry in statuses.iter() {
4785        let s = entry.status();
4786        if s.contains(Status::WT_NEW) {
4787            untracked += 1;
4788        }
4789        if s.intersects(tracked) {
4790            dirty += 1;
4791        }
4792    }
4793    (dirty, untracked)
4794}
4795
4796/// Counts commits reachable from the (detached) HEAD but from no other ref —
4797/// the commits git would garbage-collect once the worktree's HEAD is gone.
4798/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
4799/// skipped (hiding it would hide the very commits we are counting); every real
4800/// branch/tag/remote ref is hidden, so a tip that any branch also points at
4801/// yields `0` (nothing is actually lost).
4802fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4803    let head_oid = repo.head().ok()?.target()?;
4804    let mut walk = repo.revwalk().ok()?;
4805    walk.push(head_oid).ok()?;
4806    for reference in repo.references().ok()? {
4807        let Ok(reference) = reference else { continue };
4808        // Skip the literal HEAD ref — hiding it would hide the very commits we
4809        // are counting; every real branch/tag/remote ref is hidden below.
4810        if matches!(reference.name(), Ok("HEAD")) {
4811            continue;
4812        }
4813        if let Some(oid) = reference.target() {
4814            let _ = walk.hide(oid);
4815        }
4816    }
4817    Some(walk.flatten().count())
4818}
4819
4820/// Commits the worktree's current branch is ahead of its upstream, or `None`
4821/// when HEAD is detached or the branch tracks no upstream. Reuses
4822/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
4823fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4824    let head = repo.head().ok()?;
4825    if !head.is_branch() {
4826        return None;
4827    }
4828    let branch = git2::Branch::wrap(head);
4829    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4830}
4831
4832/// Resolves the linked worktree whose working directory canonicalizes to
4833/// `target` to its registered name in `main_repo`. Errors when `target` is not
4834/// one of the repo's worktrees — the defensive guard against removing a path
4835/// that opened as a worktree but is not enumerated. Split out so that guard is
4836/// unit-testable without corrupting git's worktree admin state.
4837fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4838    let names = main_repo.worktrees()?;
4839    names
4840        .iter()
4841        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
4842        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
4843        .find(|name| {
4844            main_repo
4845                .find_worktree(name)
4846                .is_ok_and(|wt| canonical(wt.path()) == target)
4847        })
4848        .map(str::to_string)
4849        .ok_or_else(|| {
4850            anyhow!(
4851                "worktree {} is not registered in {}",
4852                target.display(),
4853                main_repo.path().display()
4854            )
4855        })
4856}
4857
4858/// Backoff delays between recursive-removal retries (#1315). A concurrent
4859/// writer — a just-closed window's language server (Metals/Bloop) or
4860/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
4861/// create a file between our directory scan and its `rmdir`, making the removal
4862/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
4863/// waits longer, giving the winding-down process time to quiesce. Total wait
4864/// ~2.75s across four retries; the window teardown the caller already waited on
4865/// dominates it.
4866const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4867    Duration::from_millis(250),
4868    Duration::from_millis(500),
4869    Duration::from_secs(1),
4870    Duration::from_secs(1),
4871];
4872
4873/// Whether `e` is the transient "directory re-populated under us" race we retry
4874/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
4875/// denied, read-only filesystem) we must surface immediately. Matches the raw
4876/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
4877/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
4878fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4879    matches!(
4880        e.raw_os_error(),
4881        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4882    )
4883}
4884
4885/// Recursively removes `dir`, retrying on the transient concurrent-writer race
4886/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
4887/// as success. Non-transient errors surface immediately with the original
4888/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
4889/// `spawn_blocking`), so the between-retry `sleep` is fine.
4890fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4891    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4892}
4893
4894/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
4895/// Provoking the real race requires a concurrent writer to lose a timing window,
4896/// so only an injected sequence of errors can drive every branch of the loop —
4897/// exhausting the backoff especially — deterministically and without sleeping out
4898/// the production schedule.
4899fn remove_dir_all_retrying_with(
4900    dir: &Path,
4901    backoff: &[Duration],
4902    mut remove: impl FnMut() -> std::io::Result<()>,
4903) -> Result<()> {
4904    let mut backoff = backoff.iter();
4905    loop {
4906        match remove() {
4907            Ok(()) => return Ok(()),
4908            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4909            Err(e) => {
4910                if is_transient_rmdir_error(&e) {
4911                    if let Some(delay) = backoff.next() {
4912                        std::thread::sleep(*delay);
4913                        continue;
4914                    }
4915                }
4916                return Err(e).with_context(|| {
4917                    format!("failed to remove worktree directory {}", dir.display())
4918                });
4919            }
4920        }
4921    }
4922}
4923
4924/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
4925/// still points at an admin directory a prior failed removal already deleted.
4926/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
4927/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
4928/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
4929/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
4930/// opens) and a normal checkout has a `.git` *directory*, so this matches
4931/// neither.
4932fn is_orphaned_worktree(path: &Path) -> bool {
4933    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
4934    // a linked worktree's gitlink file gets past here.
4935    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4936        return false;
4937    };
4938    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4939        return false;
4940    };
4941    let admin = Path::new(admin);
4942    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
4943    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4944}
4945
4946/// The outcome of a linked-worktree removal, so the audit log can tell "actually
4947/// removed something" from "nothing was there" (#1403). Before this the
4948/// working-tree-gone-but-admin-present case returned `Ok(())` and logged a
4949/// `pruned` lie, leaving the row stuck in the tree view.
4950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4951enum Removal {
4952    /// The admin metadata (and possibly the working tree) was actually removed.
4953    Pruned,
4954    /// Nothing was there to remove — a truly already-removed worktree.
4955    AlreadyGone,
4956}
4957
4958/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
4959/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
4960/// directory and the admin metadata. Refuses the main working tree (the
4961/// defensive backstop behind the UI gating) and a locked worktree (surfacing
4962/// "unlock first" rather than forcing past the lock). Idempotent: an
4963/// already-removed path is a success.
4964///
4965/// The working tree is removed **first** (retrying to absorb the
4966/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
4967/// This is deliberately the reverse of libgit2's combined
4968/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
4969/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
4970/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
4971/// is already gone). Doing the directory first means a transient failure leaves
4972/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
4973/// the old ordering is detected and its leftover directory cleaned up directly.
4974///
4975/// A working directory that is *already gone* is **not** blindly treated as a
4976/// no-op: a half-removal from outside the daemon (a manual `rm -rf`, an OS
4977/// cleanup) can leave the main repo's `.git/worktrees/<name>/` admin entry behind
4978/// (git marks it `prunable` and the tree view keeps showing the row). That path
4979/// hands off to [`prune_orphaned_admin`], which locates the owning main repo from
4980/// `windows` (or the path's ancestors) and prunes just that entry; only when no
4981/// repo still tracks the path is it reported [`Removal::AlreadyGone`] (#1403).
4982fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4983    if !path.exists() {
4984        return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4985    }
4986    let repo = match Repository::open(path) {
4987        Ok(repo) => repo,
4988        // Admin metadata already gone (a prior failed removal); git no longer
4989        // tracks this path, so no prune applies — just delete the leftover.
4990        Err(_) if is_orphaned_worktree(path) => {
4991            remove_dir_all_retrying(path)?;
4992            return Ok(Removal::Pruned);
4993        }
4994        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4995    };
4996    if !repo.is_worktree() {
4997        bail!(
4998            "refusing to delete the main working tree: {}",
4999            path.display()
5000        );
5001    }
5002    // The Worktree handle lives on the *main* repo (the common dir's parent),
5003    // keyed by name; find it by matching the target path.
5004    let commondir = canonical(repo.commondir());
5005    let main_root = commondir
5006        .parent()
5007        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5008        .to_path_buf();
5009    // Drop the worktree-scoped handle before we delete its directory.
5010    drop(repo);
5011    let main_repo = Repository::open(&main_root)
5012        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5013    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5014    let worktree = main_repo.find_worktree(&name)?;
5015
5016    // Never silently force past a lock (failure mode #6).
5017    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5018        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5019        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5020    }
5021
5022    // Delete the checked-out directory ourselves, retrying past the
5023    // concurrent-writer race (#1315).
5024    remove_dir_all_retrying(path)?;
5025
5026    // The directory is gone; prune only the admin metadata. working_tree(false)
5027    // keeps git2 from re-attempting (and failing on) the now-absent directory;
5028    // valid(true) prunes even though the worktree was valid; locked stays false,
5029    // so a lock (re-checked above) is never forced.
5030    let mut opts = git2::WorktreePruneOptions::new();
5031    opts.valid(true).working_tree(false);
5032    worktree
5033        .prune(Some(&mut opts))
5034        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5035    Ok(Removal::Pruned)
5036}
5037
5038/// The main-repo roots to search when pruning an orphaned worktree whose working
5039/// directory is already gone (#1403). There is no on-disk breadcrumb from the
5040/// vanished working tree back to its repo — the `.git` gitlink lived *inside* the
5041/// deleted directory — so the owner has to be found by enumerating candidates:
5042///
5043/// - **the path's own ancestors**, covering a worktree nested under its repo
5044///   (e.g. `<repo>/.claude/worktrees/<name>`): an existing ancestor that opens as
5045///   the *main* checkout is the owner. `Repository::open` (not `discover`) so only
5046///   a real repo-root ancestor matches, never an intermediate directory.
5047/// - **every main repo the live `windows` resolve to**, covering an external
5048///   worktree that shares no ancestor with its repo. These are the same repos
5049///   whose `worktrees()` enumeration produced the orphaned row, so this is
5050///   guaranteed to include the owner whenever the UI could show a row to close.
5051///
5052/// Deduped, order-preserving (ancestors first).
5053fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5054    let mut roots: Vec<PathBuf> = Vec::new();
5055    let mut push = |root: PathBuf| {
5056        if !roots.contains(&root) {
5057            roots.push(root);
5058        }
5059    };
5060    // Skip `path` itself (gone) via `skip(1)`.
5061    for ancestor in path.ancestors().skip(1) {
5062        if let Ok(repo) = Repository::open(ancestor) {
5063            if !repo.is_worktree() {
5064                if let Some(root) = canonical(repo.commondir()).parent() {
5065                    push(root.to_path_buf());
5066                }
5067            }
5068        }
5069    }
5070    for folder in windows.iter().flat_map(|w| &w.folders) {
5071        if let Ok(repo) = Repository::discover(folder) {
5072            if let Some(root) = canonical(repo.commondir()).parent() {
5073                push(root.to_path_buf());
5074            }
5075        }
5076    }
5077    roots
5078}
5079
5080/// Prunes the leftover `.git/worktrees/<name>/` admin metadata of a worktree
5081/// whose working directory is already gone (#1403). Searches
5082/// `candidate_main_repos` for the main repo that still tracks a worktree
5083/// registered at `path`, prunes just that entry's metadata (`working_tree(false)`
5084/// — the checkout is already gone), and returns [`Removal::Pruned`]. When no
5085/// candidate still tracks the path it is truly already-removed:
5086/// [`Removal::AlreadyGone`]. A locked entry is refused, mirroring
5087/// [`remove_worktree`]'s live path, rather than forced past.
5088fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5089    let target = canonical(path);
5090    for root in candidate_main_repos {
5091        let Ok(main_repo) = Repository::open(root) else {
5092            continue;
5093        };
5094        // Only the main checkout carries the `.git/worktrees/<name>/` admin dir.
5095        if main_repo.is_worktree() {
5096            continue;
5097        }
5098        // Not the owner (or the entry is already pruned) — keep looking.
5099        let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5100            continue;
5101        };
5102        let worktree = main_repo.find_worktree(&name)?;
5103        if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5104            let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5105            bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5106        }
5107        let mut opts = git2::WorktreePruneOptions::new();
5108        opts.valid(true).working_tree(false);
5109        worktree.prune(Some(&mut opts)).with_context(|| {
5110            format!(
5111                "failed to prune orphaned worktree metadata for {}",
5112                path.display()
5113            )
5114        })?;
5115        return Ok(Removal::Pruned);
5116    }
5117    Ok(Removal::AlreadyGone)
5118}
5119
5120#[cfg(test)]
5121#[allow(clippy::unwrap_used, clippy::expect_used)]
5122mod tests {
5123    use super::*;
5124    use crate::test_support::shim::{
5125        retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5126    };
5127    use std::sync::MutexGuard;
5128
5129    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5130        json!({
5131            "key": key,
5132            "folders": [folder],
5133            "repo": repo,
5134            "title": format!("{key}-title"),
5135            "pid": 1234,
5136        })
5137    }
5138
5139    /// Pulls the `windows` array out of a `list`/`status` payload.
5140    fn windows_of(payload: &Value) -> &Vec<Value> {
5141        payload
5142            .get("windows")
5143            .and_then(Value::as_array)
5144            .expect("windows array")
5145    }
5146
5147    #[tokio::test]
5148    async fn name_and_unknown_op() {
5149        let svc = WorktreesService::new();
5150        assert_eq!(svc.name(), "worktrees");
5151        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5152    }
5153
5154    #[tokio::test]
5155    async fn handle_routes_ops_and_shapes_payloads() {
5156        let svc = WorktreesService::new();
5157        // Empty to start.
5158        let payload = svc.handle("list", Value::Null).await.unwrap();
5159        assert_eq!(payload, json!({ "windows": [] }));
5160
5161        // register → { ok: true }, then it shows up in list.
5162        let reply = svc
5163            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5164            .await
5165            .unwrap();
5166        assert_eq!(reply, json!({ "ok": true }));
5167        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5168        assert_eq!(windows.len(), 1);
5169        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5170        assert!(windows[0].get("last_seen").is_some());
5171
5172        // heartbeat known/unknown.
5173        let known = svc
5174            .handle("heartbeat", json!({ "key": "w1" }))
5175            .await
5176            .unwrap();
5177        assert_eq!(known, json!({ "known": true }));
5178        let unknown = svc
5179            .handle("heartbeat", json!({ "key": "nope" }))
5180            .await
5181            .unwrap();
5182        assert_eq!(unknown, json!({ "known": false }));
5183
5184        // reload signals a live window and reports one it does not know.
5185        let reloaded = svc
5186            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5187            .await
5188            .unwrap();
5189        assert_eq!(
5190            reloaded,
5191            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5192        );
5193        assert!(svc.registry.take_reload_pending("w1"));
5194
5195        // unregister removes, then repeats as a no-op success.
5196        let gone = svc
5197            .handle("unregister", json!({ "key": "w1" }))
5198            .await
5199            .unwrap();
5200        assert_eq!(gone, json!({ "removed": true }));
5201        let again = svc
5202            .handle("unregister", json!({ "key": "w1" }))
5203            .await
5204            .unwrap();
5205        assert_eq!(again, json!({ "removed": false }));
5206    }
5207
5208    // --- Reposition op (#1407) ------------------------------------------------
5209
5210    /// A window backend over an in-memory window table that **actually applies**
5211    /// writes, so the adapter's own responsibilities — key resolution, the undo
5212    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
5213    /// no Accessibility grant. The planner/matcher itself is covered by
5214    /// `geometry`'s own tests.
5215    ///
5216    /// Applying the writes is what makes a reposition-then-undo round trip mean
5217    /// anything: against a fixed table the restore would find every window already
5218    /// in its wanted position and correctly report `unchanged`.
5219    #[derive(Clone)]
5220    struct StubBackend {
5221        trusted: bool,
5222        /// One application's windows. `Arc` so every clone the factory hands out —
5223        /// and every op in a test — shares the same mutating table.
5224        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5225        /// Every frame written, in order.
5226        writes: Arc<Mutex<Vec<geometry::Frame>>>,
5227    }
5228
5229    impl StubBackend {
5230        /// One application (pid 900) with two default-format VS Code windows, and
5231        /// two ext-host pids (11, 12) mapping onto it.
5232        fn new(trusted: bool) -> Self {
5233            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5234                title: title.to_string(),
5235                frame: geometry::Frame {
5236                    x,
5237                    y: 0.0,
5238                    width,
5239                    height: 600.0,
5240                },
5241                minimized: false,
5242                fullscreen: false,
5243                standard: true,
5244                focused: false,
5245            };
5246            Self {
5247                trusted,
5248                windows: Arc::new(Mutex::new(vec![
5249                    window("plan.md — ref-tree", 0.0, 800.0),
5250                    window("main.rs — other-tree", 900.0, 500.0),
5251                ])),
5252                writes: Arc::new(Mutex::new(Vec::new())),
5253            }
5254        }
5255
5256        fn writes(&self) -> Vec<geometry::Frame> {
5257            self.writes
5258                .lock()
5259                .unwrap_or_else(PoisonError::into_inner)
5260                .clone()
5261        }
5262
5263        /// The frame a window currently occupies, after any applied writes.
5264        fn frame_of(&self, index: usize) -> geometry::Frame {
5265            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5266        }
5267
5268        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
5269        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5270            let clone = self.clone();
5271            move || clone
5272        }
5273    }
5274
5275    impl geometry::WindowBackend for StubBackend {
5276        fn trusted(&self) -> bool {
5277            self.trusted
5278        }
5279
5280        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5281            pids.iter()
5282                .filter(|p| **p == 11 || **p == 12)
5283                .map(|p| (*p, 900))
5284                .collect()
5285        }
5286
5287        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5288            if app_pid != 900 {
5289                return Ok(Vec::new());
5290            }
5291            Ok(self
5292                .windows
5293                .lock()
5294                .unwrap_or_else(PoisonError::into_inner)
5295                .clone())
5296        }
5297
5298        fn set_frame(
5299            &self,
5300            id: geometry::WindowId,
5301            frame: geometry::Frame,
5302        ) -> Result<geometry::Frame, String> {
5303            self.writes
5304                .lock()
5305                .unwrap_or_else(PoisonError::into_inner)
5306                .push(frame);
5307            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5308            let window = windows
5309                .get_mut(id.index)
5310                .ok_or_else(|| format!("no window at index {}", id.index))?;
5311            window.frame = frame;
5312            Ok(frame)
5313        }
5314    }
5315
5316    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
5317    /// one the stub backend can resolve to an OS window.
5318    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5319        svc.registry.register(
5320            serde_json::from_value(json!({
5321                "key": key,
5322                "folders": [format!("/tmp/{key}")],
5323                "title": title,
5324                "pid": pid,
5325            }))
5326            .expect("valid register payload"),
5327        );
5328    }
5329
5330    #[tokio::test]
5331    async fn reposition_requires_a_resolvable_reference() {
5332        let svc = WorktreesService::new();
5333        // A missing, blank, or unknown reference key is a hard error: with no
5334        // reference there is no geometry to copy, so the request is meaningless.
5335        assert!(svc.handle("reposition", json!({})).await.is_err());
5336        assert!(svc
5337            .handle("reposition", json!({ "reference_key": "  " }))
5338            .await
5339            .is_err());
5340        assert!(svc
5341            .handle("reposition", json!({ "reference_key": "ghost" }))
5342            .await
5343            .is_err());
5344    }
5345
5346    #[tokio::test]
5347    async fn reposition_moves_targets_and_records_an_undo() {
5348        let svc = WorktreesService::new();
5349        register_window(&svc, "ref", "ref-tree", 11);
5350        register_window(&svc, "other", "other-tree", 12);
5351        let backend = StubBackend::new(true);
5352
5353        let reply = svc
5354            .reposition_with(
5355                serde_json::from_value(json!({
5356                    "reference_key": "ref",
5357                    "target_keys": ["other"],
5358                }))
5359                .unwrap(),
5360                backend.factory(),
5361            )
5362            .await
5363            .unwrap();
5364
5365        assert_eq!(reply["trusted"], json!(true));
5366        assert_eq!(reply["moved"], json!(1));
5367        assert_eq!(reply["skipped"], json!(0));
5368        assert_eq!(reply["undoable"], json!(true));
5369        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5370        assert_eq!(reply["results"][0]["key"], json!("other"));
5371        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5372        // Written the reference's own frame, read from the stub's window table.
5373        assert_eq!(backend.writes().len(), 1);
5374        assert_eq!(
5375            backend.writes()[0],
5376            backend.frame_of(0),
5377            "wrote the reference window's own frame"
5378        );
5379        assert_eq!(
5380            backend.frame_of(1),
5381            backend.frame_of(0),
5382            "the target now occupies the reference's frame"
5383        );
5384
5385        // Undo puts it back where it was — the same backend, so it sees the window
5386        // where the move left it — and consumes the record, so a second undo has
5387        // nothing left to replay onto a layout the user may have since redone.
5388        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5389        assert_eq!(undone["moved"], json!(1));
5390        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5391        assert!(undone.get("reference").is_none(), "undo has no reference");
5392        assert_eq!(
5393            backend.frame_of(1),
5394            geometry::Frame {
5395                x: 900.0,
5396                y: 0.0,
5397                width: 500.0,
5398                height: 600.0,
5399            },
5400            "restored to exactly the pre-move frame"
5401        );
5402
5403        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5404        assert_eq!(
5405            again,
5406            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5407        );
5408    }
5409
5410    #[tokio::test]
5411    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5412        let svc = WorktreesService::new();
5413        register_window(&svc, "ref", "ref-tree", 11);
5414        register_window(&svc, "other", "other-tree", 12);
5415        let backend = StubBackend::new(true);
5416
5417        let reply = svc
5418            .reposition_with(
5419                serde_json::from_value(json!({
5420                    "reference_key": "ref",
5421                    "target_keys": ["other"],
5422                    "check": true,
5423                }))
5424                .unwrap(),
5425                backend.factory(),
5426            )
5427            .await
5428            .unwrap();
5429
5430        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5431        assert!(
5432            reply.get("undoable").is_none(),
5433            "a dry run leaves nothing to undo"
5434        );
5435        assert!(
5436            backend.writes().is_empty(),
5437            "a dry run must not touch a window"
5438        );
5439    }
5440
5441    #[tokio::test]
5442    async fn reposition_reports_a_missing_permission_as_data() {
5443        let svc = WorktreesService::new();
5444        register_window(&svc, "ref", "ref-tree", 11);
5445        register_window(&svc, "other", "other-tree", 12);
5446        let backend = StubBackend::new(false);
5447
5448        let reply = svc
5449            .reposition_with(
5450                serde_json::from_value(json!({
5451                    "reference_key": "ref",
5452                    "target_keys": ["other"],
5453                }))
5454                .unwrap(),
5455                backend.factory(),
5456            )
5457            .await
5458            .unwrap();
5459
5460        // Not an error: the client branches on `trusted` to offer the user a link
5461        // to the Accessibility settings pane.
5462        assert_eq!(reply["trusted"], json!(false));
5463        assert_eq!(reply["moved"], json!(0));
5464        assert!(backend.writes().is_empty());
5465    }
5466
5467    #[tokio::test]
5468    async fn a_stale_target_key_is_skipped_not_fatal() {
5469        let svc = WorktreesService::new();
5470        register_window(&svc, "ref", "ref-tree", 11);
5471        register_window(&svc, "other", "other-tree", 12);
5472        let backend = StubBackend::new(true);
5473
5474        let reply = svc
5475            .reposition_with(
5476                serde_json::from_value(json!({
5477                    "reference_key": "ref",
5478                    // A window that closed since the tree row was rendered, the
5479                    // reference itself, and a real target.
5480                    "target_keys": ["closed-since", "ref", "other"],
5481                }))
5482                .unwrap(),
5483                backend.factory(),
5484            )
5485            .await
5486            .unwrap();
5487
5488        let outcomes: Vec<&str> = reply["results"]
5489            .as_array()
5490            .unwrap()
5491            .iter()
5492            .map(|r| r["outcome"].as_str().unwrap())
5493            .collect();
5494        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5495        assert_eq!(reply["moved"], json!(1));
5496        assert_eq!(reply["skipped"], json!(2));
5497    }
5498
5499    #[tokio::test]
5500    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5501        let svc = WorktreesService::new();
5502        // Two windows share a root name, so the *reference* cannot be resolved and
5503        // the whole batch is refused before any target is attempted.
5504        register_window(&svc, "ref", "twin", 11);
5505        register_window(&svc, "other", "other-tree", 12);
5506        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5507        // `mut` binding — and the same shared table backs the factory's clone.
5508        let backend = StubBackend::new(true);
5509        {
5510            let mut windows = backend
5511                .windows
5512                .lock()
5513                .unwrap_or_else(PoisonError::into_inner);
5514            windows[0].title = "a.rs — twin".to_string();
5515            windows[1].title = "b.rs — twin".to_string();
5516        }
5517
5518        let reply = svc
5519            .reposition_with(
5520                serde_json::from_value(json!({
5521                    "reference_key": "ref",
5522                    "target_keys": ["other"],
5523                }))
5524                .unwrap(),
5525                backend.factory(),
5526            )
5527            .await
5528            .unwrap();
5529
5530        assert_eq!(reply["trusted"], json!(true));
5531        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5532        assert!(
5533            reply["blocked"]["detail"]
5534                .as_str()
5535                .is_some_and(|d| d.contains("twin")),
5536            "the reason should name the ambiguous title: {reply}"
5537        );
5538        assert_eq!(reply["results"], json!([]), "no target is attempted");
5539        assert!(reply.get("undoable").is_none());
5540        assert!(backend.writes().is_empty());
5541
5542        // And nothing was recorded, so a following undo has nothing to replay.
5543        let undone = svc
5544            .reposition_undo_with(StubBackend::new(true).factory())
5545            .await
5546            .unwrap();
5547        assert_eq!(undone["moved"], json!(0));
5548    }
5549
5550    #[tokio::test]
5551    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5552        let svc = WorktreesService::new();
5553        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5554        assert_eq!(reply["moved"], json!(0));
5555        assert_eq!(reply["results"], json!([]));
5556    }
5557
5558    #[test]
5559    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5560        let empty = geometry::RepositionReport {
5561            trusted: true,
5562            blocked: None,
5563            reference: None,
5564            results: Vec::new(),
5565            undo: Vec::new(),
5566        };
5567        assert_eq!(outcome_kinds(&empty), "-");
5568    }
5569
5570    #[tokio::test]
5571    async fn handle_rejects_missing_or_empty_key() {
5572        let svc = WorktreesService::new();
5573        // register validates a present, non-blank key.
5574        assert!(svc.handle("register", json!({})).await.is_err());
5575        assert!(svc
5576            .handle("register", json!({ "key": "  " }))
5577            .await
5578            .is_err());
5579        // heartbeat/unregister require the key via `require_str`.
5580        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5581        assert!(svc.handle("unregister", json!({})).await.is_err());
5582    }
5583
5584    #[test]
5585    fn display_name_prefers_repo_then_folder_basename() {
5586        let base = WindowEntry {
5587            key: "k".to_string(),
5588            folders: vec![PathBuf::from("/home/me/project")],
5589            repo: Some("my-repo".to_string()),
5590            title: None,
5591            pid: None,
5592            last_seen: Utc::now(),
5593        };
5594        assert_eq!(display_name(&base), "my-repo");
5595
5596        let no_repo = WindowEntry {
5597            repo: None,
5598            ..base.clone()
5599        };
5600        assert_eq!(display_name(&no_repo), "project");
5601
5602        let nothing = WindowEntry {
5603            repo: None,
5604            folders: vec![],
5605            ..base.clone()
5606        };
5607        assert_eq!(display_name(&nothing), "(no folder)");
5608
5609        // A folder with no basename (the filesystem root) falls back to its
5610        // displayed path rather than panicking or yielding an empty name.
5611        let rootish = WindowEntry {
5612            repo: None,
5613            folders: vec![PathBuf::from("/")],
5614            ..base
5615        };
5616        assert_eq!(display_name(&rootish), "/");
5617    }
5618
5619    #[test]
5620    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5621        let now = Utc::now();
5622        let entries = vec![
5623            // A folderless window has nothing to focus, so it stays a plain
5624            // Label; a title equal to the name collapses to just the name. It
5625            // leads the list so the focus-action lookup below is exercised
5626            // against a leading non-Action item it has to skip.
5627            WindowEntry {
5628                key: "k2".to_string(),
5629                folders: vec![],
5630                repo: Some("solo".to_string()),
5631                title: Some("solo".to_string()),
5632                pid: None,
5633                last_seen: now,
5634            },
5635            // A folder-bearing, non-repo window: one clickable Action whose label
5636            // is the stats line ("name · title", since /tmp is not a git repo).
5637            WindowEntry {
5638                key: "k1".to_string(),
5639                folders: vec![PathBuf::from("/tmp/a")],
5640                repo: Some("repo".to_string()),
5641                title: Some("a branch".to_string()),
5642                pid: None,
5643                last_seen: now,
5644            },
5645        ];
5646        let items = window_menu_items(&entries);
5647        // Exactly one item per window — no duplicate label, no separator.
5648        assert_eq!(items.len(), 2);
5649        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5650
5651        // The folder-bearing window is a single clickable action carrying the
5652        // stats label (the old label + Focus action, merged).
5653        let action = items
5654            .iter()
5655            .find_map(|i| match i {
5656                MenuItem::Action(a) => Some(a),
5657                _ => None,
5658            })
5659            .expect("a focus action");
5660        assert_eq!(action.id, "focus:k1");
5661        assert_eq!(action.label, "repo · a branch");
5662
5663        // The folderless window is a non-clickable label (not "solo · solo").
5664        let labels: Vec<&str> = items
5665            .iter()
5666            .filter_map(|i| match i {
5667                MenuItem::Label(t) => Some(t.as_str()),
5668                _ => None,
5669            })
5670            .collect();
5671        assert_eq!(labels, vec!["solo"]);
5672    }
5673
5674    #[tokio::test]
5675    async fn menu_and_status_shapes() {
5676        let svc = WorktreesService::new();
5677        // Empty.
5678        let menu = svc.menu();
5679        assert_eq!(menu.title, "Worktrees");
5680        assert!(matches!(
5681            menu.items.first(),
5682            Some(MenuItem::Label(text)) if text == "No open windows"
5683        ));
5684        let status = svc.status().await;
5685        assert_eq!(status.name, "worktrees");
5686        assert!(status.healthy);
5687        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5688
5689        // Two folder-bearing windows in the same repo, plus one folderless
5690        // window that shares the repo but has nothing for `code` to open.
5691        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5692            .await
5693            .unwrap();
5694        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5695            .await
5696            .unwrap();
5697        svc.handle(
5698            "register",
5699            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5700        )
5701        .await
5702        .unwrap();
5703        let status = svc.status().await;
5704        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5705
5706        let menu = svc.menu();
5707        // One line per window — no separator, no duplicate label.
5708        assert_eq!(menu.items.len(), 3);
5709        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5710        let action_ids: Vec<&str> = menu
5711            .items
5712            .iter()
5713            .filter_map(|i| match i {
5714                MenuItem::Action(a) => Some(a.id.as_str()),
5715                _ => None,
5716            })
5717            .collect();
5718        // The two folder-bearing windows are clickable; the folderless one is a
5719        // plain Label, so it never yields a focus action.
5720        assert!(action_ids.contains(&"focus:w1"));
5721        assert!(action_ids.contains(&"focus:w2"));
5722        assert!(!action_ids.contains(&"focus:w3"));
5723    }
5724
5725    #[test]
5726    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5727        // With no tokio runtime, the background task is never spawned, so the
5728        // bare service keeps computing `menu()` inline (what the tests rely on).
5729        let svc = WorktreesService::new();
5730        svc.start_menu_refresh();
5731        assert!(svc.refresh.lock().unwrap().is_none());
5732    }
5733
5734    #[tokio::test]
5735    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5736        let svc = WorktreesService::new();
5737        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5738            .await
5739            .unwrap();
5740        // Before the task runs, `menu()` computes inline from an empty cache.
5741        assert!(svc.menu_cache.lock().unwrap().is_none());
5742
5743        svc.start_menu_refresh();
5744        // Idempotent: a second call does not start a second task.
5745        svc.start_menu_refresh();
5746
5747        // The task fills the cache off the main thread; poll briefly for it.
5748        let mut filled = false;
5749        for _ in 0..100 {
5750            if svc.menu_cache.lock().unwrap().is_some() {
5751                filled = true;
5752                break;
5753            }
5754            tokio::time::sleep(Duration::from_millis(10)).await;
5755        }
5756        assert!(filled, "background refresh should populate the menu cache");
5757
5758        // `menu()` now serves the cache: one clickable line for the window.
5759        let menu = svc.menu();
5760        assert_eq!(menu.title, "Worktrees");
5761        assert!(menu
5762            .items
5763            .iter()
5764            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5765
5766        // Shutdown cancels and joins the task, clearing the handle.
5767        svc.shutdown().await;
5768        assert!(svc.refresh.lock().unwrap().is_none());
5769    }
5770
5771    #[tokio::test]
5772    async fn default_constructs_an_empty_service() {
5773        let svc = WorktreesService::default();
5774        let payload = svc.handle("list", Value::Null).await.unwrap();
5775        assert_eq!(payload, json!({ "windows": [] }));
5776    }
5777
5778    // --- Push subscription (#1267) -----------------------------------------
5779
5780    #[tokio::test]
5781    async fn subscribe_streams_only_for_the_subscribe_op() {
5782        let svc = WorktreesService::new();
5783        // The one streaming op yields a stream; every other op (including the
5784        // request/reply worktrees ops) declines, so the server dispatches them
5785        // normally.
5786        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5787        assert!(svc.subscribe("list", &Value::Null).is_none());
5788        assert!(svc.subscribe("register", &Value::Null).is_none());
5789        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5790    }
5791
5792    #[tokio::test]
5793    async fn subscribe_snapshot_matches_the_tree_op() {
5794        let dir = tempfile::tempdir().unwrap();
5795        let repo = init_repo(dir.path());
5796        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5797        repo.set_head("refs/heads/main").unwrap();
5798
5799        let svc = WorktreesService::new();
5800        let stream = svc
5801            .subscribe("subscribe", &Value::Null)
5802            .expect("subscribe stream");
5803        // No windows yet → no repos derived; the toggle rides along at its
5804        // default (show all).
5805        assert_eq!(
5806            stream.snapshot().await,
5807            json!({ "repos": [], "show_closed": true })
5808        );
5809
5810        // A window opens on the repo → the snapshot carries it, byte-identical to
5811        // what the `tree` op returns for the same registry state.
5812        svc.handle(
5813            "register",
5814            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5815        )
5816        .await
5817        .unwrap();
5818        let snap = stream.snapshot().await;
5819        let tree = svc.handle("tree", Value::Null).await.unwrap();
5820        assert_eq!(snap, tree);
5821        let repos = snap["repos"].as_array().expect("repos array");
5822        assert_eq!(repos.len(), 1);
5823        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5824    }
5825
5826    #[tokio::test]
5827    async fn subscribe_changed_wakes_on_register() {
5828        let svc = WorktreesService::new();
5829        let mut stream = svc
5830            .subscribe("subscribe", &Value::Null)
5831            .expect("subscribe stream");
5832        // Idle: `changed()` must not resolve without a registry change.
5833        tokio::select! {
5834            () = stream.changed() => panic!("changed resolved with no registry change"),
5835            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5836        }
5837        // A register bumps the change-notify → `changed()` resolves promptly.
5838        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5839            .await
5840            .unwrap();
5841        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5842            .await
5843            .expect("changed should resolve after a register");
5844    }
5845
5846    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5847
5848    #[tokio::test]
5849    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5850        let reg = Arc::new(WorktreesRegistry::new());
5851        // A long TTL so only the generation gate is exercised here.
5852        let cache = TreeSnapshotCache::with_ttl(
5853            reg,
5854            Arc::new(PrStatusCache::new()),
5855            Duration::from_secs(60),
5856        );
5857        // The first read builds once.
5858        let first = cache.snapshot().await;
5859        assert_eq!(cache.compute_count(), 1);
5860        // Further reads with no registry change and within the TTL reuse the
5861        // cached value — no extra build, byte-identical result.
5862        let second = cache.snapshot().await;
5863        assert_eq!(
5864            cache.compute_count(),
5865            1,
5866            "an unchanged read must not rebuild"
5867        );
5868        assert_eq!(first, second);
5869    }
5870
5871    #[tokio::test]
5872    async fn tree_cache_single_flights_a_read_burst() {
5873        let reg = Arc::new(WorktreesRegistry::new());
5874        let cache = Arc::new(TreeSnapshotCache::with_ttl(
5875            reg,
5876            Arc::new(PrStatusCache::new()),
5877            Duration::from_secs(60),
5878        ));
5879        // A burst of concurrent readers — as N subscriber streams would wake
5880        // together on a change/tick — collapses to exactly one build; the rest
5881        // read the shared result (the acceptance criterion).
5882        let mut handles = Vec::new();
5883        for _ in 0..16 {
5884            let cache = cache.clone();
5885            handles.push(tokio::spawn(async move { cache.snapshot().await }));
5886        }
5887        let mut results = Vec::new();
5888        for handle in handles {
5889            results.push(handle.await.unwrap());
5890        }
5891        assert_eq!(
5892            cache.compute_count(),
5893            1,
5894            "a concurrent read burst must build the tree once"
5895        );
5896        assert!(
5897            results.windows(2).all(|w| w[0] == w[1]),
5898            "every reader must observe the identical snapshot"
5899        );
5900    }
5901
5902    #[tokio::test]
5903    async fn tree_cache_rebuilds_on_registry_change() {
5904        let reg = Arc::new(WorktreesRegistry::new());
5905        let cache = TreeSnapshotCache::with_ttl(
5906            reg.clone(),
5907            Arc::new(PrStatusCache::new()),
5908            Duration::from_secs(60),
5909        );
5910        cache.snapshot().await;
5911        assert_eq!(cache.compute_count(), 1);
5912        // A registry change bumps the generation, so the next read rebuilds even
5913        // though the (long) TTL has not expired — subscribers never see a stale
5914        // visible set.
5915        assert!(reg.set_show_closed(false));
5916        cache.snapshot().await;
5917        assert_eq!(
5918            cache.compute_count(),
5919            2,
5920            "a generation bump must force a rebuild"
5921        );
5922    }
5923
5924    #[tokio::test]
5925    async fn tree_cache_rebuilds_after_ttl_expiry() {
5926        let reg = Arc::new(WorktreesRegistry::new());
5927        // A zero TTL: every read is already past it, so a pure on-disk git change
5928        // still surfaces on the next tick with no registry bump needed.
5929        let cache =
5930            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5931        cache.snapshot().await;
5932        cache.snapshot().await;
5933        assert_eq!(
5934            cache.compute_count(),
5935            2,
5936            "an expired TTL must force a rebuild each read"
5937        );
5938    }
5939
5940    #[tokio::test]
5941    async fn subscribe_streams_share_one_build_per_generation() {
5942        let svc = WorktreesService::new();
5943        let s1 = svc
5944            .subscribe("subscribe", &Value::Null)
5945            .expect("subscribe stream");
5946        let s2 = svc
5947            .subscribe("subscribe", &Value::Null)
5948            .expect("subscribe stream");
5949        // Two windows' streams sampling the same registry state build the tree
5950        // once, not once per stream (#1303) — they share the service's cache.
5951        let a = s1.snapshot().await;
5952        let b = s2.snapshot().await;
5953        assert_eq!(a, b);
5954        assert_eq!(
5955            svc.tree_cache.compute_count(),
5956            1,
5957            "N streams on one generation must share a single build"
5958        );
5959    }
5960
5961    // --- Show/hide-closed toggle (#1301) -----------------------------------
5962
5963    #[tokio::test]
5964    async fn set_show_closed_toggles_the_snapshot_field() {
5965        let svc = WorktreesService::new();
5966        // The snapshot carries the toggle; it defaults to show-all.
5967        assert_eq!(
5968            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5969            json!(true)
5970        );
5971        // Setting it flips the field the next snapshot reports.
5972        let reply = svc
5973            .handle("set-show-closed", json!({ "show_closed": false }))
5974            .await
5975            .unwrap();
5976        assert_eq!(reply, json!({ "ok": true }));
5977        assert_eq!(
5978            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5979            json!(false)
5980        );
5981    }
5982
5983    #[tokio::test]
5984    async fn set_show_closed_rejects_a_non_boolean_payload() {
5985        let svc = WorktreesService::new();
5986        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5987        assert!(svc
5988            .handle("set-show-closed", json!({ "show_closed": "yes" }))
5989            .await
5990            .is_err());
5991    }
5992
5993    #[tokio::test]
5994    async fn set_show_closed_wakes_the_subscription() {
5995        let svc = WorktreesService::new();
5996        let mut stream = svc
5997            .subscribe("subscribe", &Value::Null)
5998            .expect("subscribe stream");
5999        // A real flip bumps the change-notify → `changed()` resolves promptly.
6000        svc.handle("set-show-closed", json!({ "show_closed": false }))
6001            .await
6002            .unwrap();
6003        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6004            .await
6005            .expect("changed should resolve after a toggle flip");
6006        // The pushed snapshot now reflects the new toggle.
6007        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6008    }
6009
6010    #[tokio::test]
6011    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6012        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
6013        // drops it (skip-if-false), so the extension colours the icon off the flag.
6014        let dir = tempfile::tempdir().unwrap();
6015        github_repo(dir.path());
6016        let svc = WorktreesService::new();
6017        svc.handle(
6018            "register",
6019            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6020        )
6021        .await
6022        .unwrap();
6023
6024        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6025        assert!(
6026            repo.get("polling_enabled").is_none(),
6027            "default off omits the flag: {repo:?}"
6028        );
6029
6030        let reply = svc
6031            .handle(
6032                "set-polling",
6033                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6034            )
6035            .await
6036            .unwrap();
6037        assert_eq!(reply, json!({ "ok": true }));
6038        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6039        assert_eq!(repo["polling_enabled"], json!(true));
6040
6041        svc.handle(
6042            "set-polling",
6043            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6044        )
6045        .await
6046        .unwrap();
6047        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6048        assert!(repo.get("polling_enabled").is_none());
6049    }
6050
6051    #[tokio::test]
6052    async fn set_polling_rejects_missing_or_empty_fields() {
6053        let svc = WorktreesService::new();
6054        // Missing `enabled`.
6055        assert!(svc
6056            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6057            .await
6058            .is_err());
6059        // Missing `owner`/`name`.
6060        assert!(svc
6061            .handle("set-polling", json!({ "enabled": true }))
6062            .await
6063            .is_err());
6064        // Blank `owner`/`name`.
6065        assert!(svc
6066            .handle(
6067                "set-polling",
6068                json!({ "owner": " ", "name": "n", "enabled": true })
6069            )
6070            .await
6071            .is_err());
6072    }
6073
6074    #[tokio::test]
6075    async fn set_polling_wakes_the_subscription() {
6076        let dir = tempfile::tempdir().unwrap();
6077        github_repo(dir.path());
6078        let svc = WorktreesService::new();
6079        svc.handle(
6080            "register",
6081            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6082        )
6083        .await
6084        .unwrap();
6085        let mut stream = svc
6086            .subscribe("subscribe", &Value::Null)
6087            .expect("subscribe stream");
6088        svc.handle(
6089            "set-polling",
6090            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6091        )
6092        .await
6093        .unwrap();
6094        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6095            .await
6096            .expect("changed should resolve after enabling a repo");
6097        let repo = repos_of(&stream.snapshot().await)[0].clone();
6098        assert_eq!(repo["polling_enabled"], json!(true));
6099    }
6100
6101    #[tokio::test]
6102    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6103        // The "drop existing badges immediately" requirement (#1376), done
6104        // daemon-side: the fold skips a not-polled repo, so a disable strips the
6105        // badge on the very next snapshot rather than waiting for a poll.
6106        let dir = tempfile::tempdir().unwrap();
6107        let repo = github_repo(dir.path());
6108        let head = repo.head().unwrap().target().unwrap().to_string();
6109        let svc = WorktreesService::new();
6110        svc.handle(
6111            "register",
6112            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6113        )
6114        .await
6115        .unwrap();
6116        svc.registry.set_polling("rust-works", "omni-dev", true);
6117
6118        let mut badges = HashMap::new();
6119        badges.insert(
6120            PrTarget {
6121                owner: "rust-works".into(),
6122                name: "omni-dev".into(),
6123                branch: "main".into(),
6124            },
6125            pr(pending_badge(7, &head)),
6126        );
6127        svc.pr_cache.replace(badges);
6128
6129        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6130        assert_eq!(wt["pr"]["number"], json!(7));
6131
6132        svc.handle(
6133            "set-polling",
6134            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6135        )
6136        .await
6137        .unwrap();
6138        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6139        assert!(
6140            wt.get("pr").is_none(),
6141            "a disabled repo must carry no badge: {wt:?}"
6142        );
6143    }
6144
6145    #[tokio::test]
6146    async fn an_expired_lease_drops_the_flag_and_badges() {
6147        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
6148        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
6149        // poller would no longer watch it — all reaped on read, no timer.
6150        let dir = tempfile::tempdir().unwrap();
6151        let repo = github_repo(dir.path());
6152        let head = repo.head().unwrap().target().unwrap().to_string();
6153        let svc = WorktreesService::new();
6154        svc.handle(
6155            "register",
6156            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6157        )
6158        .await
6159        .unwrap();
6160        svc.registry.set_polling("rust-works", "omni-dev", true);
6161        let mut badges = HashMap::new();
6162        badges.insert(
6163            PrTarget {
6164                owner: "rust-works".into(),
6165                name: "omni-dev".into(),
6166                branch: "main".into(),
6167            },
6168            pr(pending_badge(7, &head)),
6169        );
6170        svc.pr_cache.replace(badges);
6171
6172        // Leased: flag stamped, badge folded, and the poller would watch it.
6173        let snap = svc.handle("tree", Value::Null).await.unwrap();
6174        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6175        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6176        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6177
6178        // Force the lease into the past — as 15 minutes elapsing would.
6179        svc.registry.set_polling_expiry(
6180            "rust-works",
6181            "omni-dev",
6182            Utc::now() - chrono::Duration::minutes(1),
6183        );
6184
6185        let snap = svc.handle("tree", Value::Null).await.unwrap();
6186        let repo0 = &repos_of(&snap)[0];
6187        assert!(
6188            repo0.get("polling_enabled").is_none(),
6189            "expired lease drops the flag: {repo0:?}"
6190        );
6191        assert!(
6192            repo0["worktrees"][0].get("pr").is_none(),
6193            "expired lease drops the badge"
6194        );
6195        assert!(
6196            pr_targets_from_snapshot(&snap).is_empty(),
6197            "the poller no longer watches an expired repo"
6198        );
6199    }
6200
6201    #[tokio::test]
6202    async fn polling_prefs_persist_across_reloads_with_0600() {
6203        // The enable set survives a daemon restart (#1376): a change writes the
6204        // `0600` file, and a fresh service seeded from it comes up enabled.
6205        let dir = tempfile::tempdir().unwrap();
6206        let prefs = dir.path().join("worktrees-polling.json");
6207
6208        let svc = WorktreesService::new();
6209        svc.load_polling_prefs(prefs.clone());
6210        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6211        svc.handle(
6212            "set-polling",
6213            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6214        )
6215        .await
6216        .unwrap();
6217        assert!(prefs.exists());
6218        #[cfg(unix)]
6219        {
6220            use std::os::unix::fs::PermissionsExt;
6221            assert_eq!(
6222                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6223                0o600
6224            );
6225        }
6226
6227        // A new service reloads the enabled set from that file.
6228        let svc2 = WorktreesService::new();
6229        svc2.load_polling_prefs(prefs.clone());
6230        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6231
6232        // Disabling rewrites the file, so the next reload has nothing enabled.
6233        svc2.handle(
6234            "set-polling",
6235            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6236        )
6237        .await
6238        .unwrap();
6239        let svc3 = WorktreesService::new();
6240        svc3.load_polling_prefs(prefs);
6241        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6242    }
6243
6244    #[test]
6245    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6246        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
6247        // path is logged and treated as "nothing enabled" rather than wedging the
6248        // service. A missing file is already the first-run default (covered by the
6249        // persistence round-trip above); this exercises the two error branches.
6250        let dir = tempfile::tempdir().unwrap();
6251
6252        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
6253        let corrupt = dir.path().join("worktrees-polling.json");
6254        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6255        let svc = WorktreesService::new();
6256        svc.load_polling_prefs(corrupt);
6257        assert!(svc.registry.enabled_polling_repos().is_empty());
6258
6259        // A directory at the path — the (non-NotFound) read error is swallowed too.
6260        let as_dir = dir.path().join("is-a-directory");
6261        std::fs::create_dir(&as_dir).unwrap();
6262        let svc2 = WorktreesService::new();
6263        svc2.load_polling_prefs(as_dir);
6264        assert!(svc2.registry.enabled_polling_repos().is_empty());
6265    }
6266
6267    #[tokio::test]
6268    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6269        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
6270        // the user has not enabled polling for it — so the poller spawns no `gh`.
6271        let dir = tempfile::tempdir().unwrap();
6272        github_repo(dir.path());
6273        let bin_dir = tempfile::tempdir().unwrap();
6274        let marker = bin_dir.path().join("spawned");
6275        let fake = bin_dir.path().join("fake-gh");
6276        std::fs::write(
6277            &fake,
6278            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6279        )
6280        .unwrap();
6281        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6282        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6283        std::fs::set_permissions(&fake, perms).unwrap();
6284
6285        let svc = WorktreesService::new();
6286        svc.handle(
6287            "register",
6288            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6289        )
6290        .await
6291        .unwrap();
6292        // Deliberately NOT enabling polling for the repo.
6293        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6294        tokio::time::sleep(Duration::from_millis(200)).await;
6295        svc.shutdown().await;
6296        assert!(
6297            !marker.exists(),
6298            "a registered-but-not-enabled repo must drive zero gh"
6299        );
6300    }
6301
6302    #[tokio::test]
6303    async fn menu_action_rejects_unknown_and_missing_window() {
6304        let svc = WorktreesService::new();
6305        assert!(svc.menu_action("bogus").await.is_err());
6306        // A focus for a key with no registration errors rather than spawning.
6307        assert!(svc.menu_action("focus:nope").await.is_err());
6308        svc.shutdown().await;
6309    }
6310
6311    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
6312    /// variable (via `resolve_code_binary` → `focus_window`) —
6313    /// `menu_action_focus_resolves_folder_and_spawns` and
6314    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
6315    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
6316    /// transient overlap under the harness's test parallelism is benign.
6317    struct VscodeBinGuard(Option<std::ffi::OsString>);
6318    impl Drop for VscodeBinGuard {
6319        fn drop(&mut self) {
6320            match self.0.take() {
6321                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6322                None => std::env::remove_var(VSCODE_BIN_ENV),
6323            }
6324        }
6325    }
6326
6327    #[tokio::test]
6328    async fn menu_action_focus_resolves_folder_and_spawns() {
6329        let dir = tempfile::tempdir().unwrap();
6330        let svc = WorktreesService::new();
6331        svc.handle(
6332            "register",
6333            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6334        )
6335        .await
6336        .unwrap();
6337
6338        // Point the launcher at a harmless binary so the spawn deterministically
6339        // succeeds and the focus path returns Ok.
6340        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6341        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6342        svc.menu_action("focus:w1").await.unwrap();
6343    }
6344
6345    #[tokio::test]
6346    async fn open_rejects_missing_relative_or_nonexistent_path() {
6347        let svc = WorktreesService::new();
6348        // A missing `path` is a payload error.
6349        assert!(svc.handle("open", json!({})).await.is_err());
6350        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6351        // A relative path is rejected before any spawn — this is also what
6352        // blocks a `-`-leading argument from reaching `code` as a flag.
6353        assert!(svc
6354            .handle("open", json!({ "path": "relative/dir" }))
6355            .await
6356            .is_err());
6357        assert!(svc
6358            .handle("open", json!({ "path": "-flag" }))
6359            .await
6360            .is_err());
6361        // An absolute path that does not exist is rejected before any spawn, so
6362        // no launcher is needed for these guard cases.
6363        assert!(svc
6364            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6365            .await
6366            .is_err());
6367        svc.shutdown().await;
6368    }
6369
6370    #[tokio::test]
6371    async fn open_focuses_an_existing_absolute_dir() {
6372        let dir = tempfile::tempdir().unwrap();
6373        let svc = WorktreesService::new();
6374        // Pin the launcher to a harmless binary so the spawn deterministically
6375        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6376        // path, `open` takes the folder straight from the payload — no prior
6377        // `register` is required.
6378        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6379        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6380        let reply = svc
6381            .handle("open", json!({ "path": dir.path() }))
6382            .await
6383            .unwrap();
6384        assert_eq!(reply, json!({ "ok": true }));
6385        svc.shutdown().await;
6386    }
6387
6388    #[test]
6389    fn focus_window_with_validates_folder_then_spawns() {
6390        let dir = tempfile::tempdir().unwrap();
6391        // Non-absolute and missing-directory folders are rejected before spawn.
6392        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6393        assert!(
6394            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6395        );
6396        // A valid absolute directory spawns the launcher successfully.
6397        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6398        // A missing launcher surfaces the spawn error (with context), not Ok.
6399        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6400    }
6401
6402    #[test]
6403    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6404        // Env override wins outright.
6405        assert_eq!(
6406            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6407            PathBuf::from("/custom/code")
6408        );
6409        // No override: the first existing candidate is chosen.
6410        let existing = tempfile::NamedTempFile::new().unwrap();
6411        let existing_path = existing.path().to_str().unwrap();
6412        assert_eq!(
6413            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6414            PathBuf::from(existing_path)
6415        );
6416        // Nothing exists: fall back to bare `code` on PATH.
6417        assert_eq!(
6418            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6419            PathBuf::from("code")
6420        );
6421        // The real-env wrapper resolves without panicking.
6422        let _ = resolve_code_binary();
6423    }
6424
6425    // --- Git enrichment (#1186) --------------------------------------------
6426
6427    /// Initializes a fresh repo with a deterministic identity so `commit()`
6428    /// works without depending on a global git config.
6429    fn init_repo(dir: &Path) -> Repository {
6430        let repo = Repository::init(dir).unwrap();
6431        let mut cfg = repo.config().unwrap();
6432        cfg.set_str("user.name", "Test").unwrap();
6433        cfg.set_str("user.email", "test@example.com").unwrap();
6434        repo
6435    }
6436
6437    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6438    /// optionally moving `refname` to it, and returns its oid.
6439    fn empty_commit(
6440        repo: &Repository,
6441        refname: Option<&str>,
6442        parents: &[&git2::Commit<'_>],
6443        msg: &str,
6444    ) -> git2::Oid {
6445        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6446        let tree = repo
6447            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6448            .unwrap();
6449        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6450            .unwrap()
6451    }
6452
6453    /// Commits `content` as file `name` onto `refname`, chaining off its current
6454    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6455    /// the file is checked out into a worktree and can then be modified to
6456    /// produce a dirty (tracked) status.
6457    fn commit_file(
6458        repo: &Repository,
6459        refname: &str,
6460        name: &str,
6461        content: &[u8],
6462        msg: &str,
6463    ) -> git2::Oid {
6464        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6465        let blob = repo.blob(content).unwrap();
6466        let mut builder = repo.treebuilder(None).unwrap();
6467        builder.insert(name, blob, 0o100_644).unwrap();
6468        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6469        let parent = repo
6470            .refname_to_id(refname)
6471            .ok()
6472            .and_then(|oid| repo.find_commit(oid).ok());
6473        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6474        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6475            .unwrap()
6476    }
6477
6478    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6479    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6480    fn diverging_repo(dir: &Path) -> Repository {
6481        let repo = init_repo(dir);
6482        // A: the shared base on `main`.
6483        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6484        let a_commit = repo.find_commit(a).unwrap();
6485        // origin/main diverges to C, a sibling of the local tip.
6486        let c = empty_commit(&repo, None, &[&a_commit], "C");
6487        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6488            .unwrap();
6489        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6490        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6491        // Release the commit's borrow of `repo` so it can be returned.
6492        drop(a_commit);
6493        repo.set_head("refs/heads/main").unwrap();
6494        // Configure the tracking relationship so `upstream()` resolves.
6495        let mut cfg = repo.config().unwrap();
6496        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6497            .unwrap();
6498        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6499            .unwrap();
6500        cfg.set_str("branch.main.remote", "origin").unwrap();
6501        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6502        repo
6503    }
6504
6505    /// Builds a repo whose `main` has **no upstream configured** but is 1 commit
6506    /// behind a resolvable `origin/main` — the "no own upstream, but behind the
6507    /// default branch" case [`folder_main_behind`] exists for (#1457). No
6508    /// `origin/HEAD` symref is set, so resolution goes through
6509    /// [`RemoteInfo::detect_main_branch_local`]'s common-names fallback.
6510    fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6511        let repo = init_repo(dir);
6512        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6513        let a_commit = repo.find_commit(a).unwrap();
6514        let c = empty_commit(&repo, None, &[&a_commit], "C");
6515        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6516            .unwrap();
6517        drop(a_commit);
6518        repo.set_head("refs/heads/main").unwrap();
6519        repo
6520    }
6521
6522    #[test]
6523    fn git_status_reads_branch_and_ahead_behind() {
6524        let dir = tempfile::tempdir().unwrap();
6525        let _repo = diverging_repo(dir.path());
6526        let status = git_status(dir.path());
6527        assert_eq!(status.branch.as_deref(), Some("main"));
6528        assert_eq!(status.ahead, Some(1));
6529        assert_eq!(status.behind, Some(1));
6530        // A normal checkout names itself and is not flagged a worktree.
6531        assert_eq!(
6532            status.main_repo.as_deref(),
6533            dir.path().file_name().and_then(|n| n.to_str())
6534        );
6535        assert!(!status.is_worktree);
6536    }
6537
6538    #[test]
6539    fn git_status_empty_repo_is_unborn() {
6540        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6541        // branch/sync fields stay empty rather than panicking — but the repo
6542        // identity is still resolved from the common dir.
6543        let dir = tempfile::tempdir().unwrap();
6544        init_repo(dir.path());
6545        let status = git_status(dir.path());
6546        assert_eq!(status.branch, None);
6547        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6548        assert_eq!(status.head_sha, None);
6549        assert_eq!(status.ahead, None);
6550        assert_eq!(status.behind, None);
6551        assert_eq!(
6552            status.main_repo.as_deref(),
6553            dir.path().file_name().and_then(|n| n.to_str())
6554        );
6555        assert!(!status.is_worktree);
6556    }
6557
6558    #[test]
6559    fn git_status_no_upstream_reports_branch_only() {
6560        let dir = tempfile::tempdir().unwrap();
6561        let repo = init_repo(dir.path());
6562        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6563        repo.set_head("refs/heads/main").unwrap();
6564        let status = git_status(dir.path());
6565        assert_eq!(status.branch.as_deref(), Some("main"));
6566        // No upstream → ahead/behind stay absent rather than zero.
6567        assert_eq!(status.ahead, None);
6568        assert_eq!(status.behind, None);
6569        // …and so does the upstream SHA, so such a branch still renders with no
6570        // sync indicator at all (#1344).
6571        assert_eq!(status.upstream_sha, None);
6572    }
6573
6574    #[test]
6575    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6576        // A plain directory that is not a git repo yields nothing at all.
6577        let plain = tempfile::tempdir().unwrap();
6578        assert_eq!(git_status(plain.path()), GitStatus::default());
6579
6580        // A detached HEAD reports no branch (and thus no sync), but the repo
6581        // identity is still resolved from the common dir.
6582        let dir = tempfile::tempdir().unwrap();
6583        let repo = init_repo(dir.path());
6584        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6585        repo.set_head_detached(a).unwrap();
6586        let status = git_status(dir.path());
6587        assert_eq!(status.branch, None);
6588        // A detached HEAD has no branch but *does* have a commit — the SHA is
6589        // resolved before the branch filter, so it survives here (#1337).
6590        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6591        assert_eq!(status.ahead, None);
6592        assert_eq!(status.behind, None);
6593        // A detached HEAD has no branch, so there is no upstream to resolve
6594        // either — the branch filter returns before the wrap (#1344).
6595        assert_eq!(status.upstream_sha, None);
6596        assert_eq!(
6597            status.main_repo.as_deref(),
6598            dir.path().file_name().and_then(|n| n.to_str())
6599        );
6600        assert!(!status.is_worktree);
6601    }
6602
6603    // --- Lazy ahead/behind (#1306) -----------------------------------------
6604
6605    #[test]
6606    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6607        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6608        // the streamed tree snapshot still reads the branch and repo identity, but
6609        // leaves ahead/behind absent — divergence is now lazy (#1306).
6610        let dir = tempfile::tempdir().unwrap();
6611        let repo = diverging_repo(dir.path());
6612        let status = git_status_cheap(dir.path());
6613        assert_eq!(status.branch.as_deref(), Some("main"));
6614        assert_eq!(status.ahead, None);
6615        assert_eq!(status.behind, None);
6616        assert_eq!(
6617            status.main_repo.as_deref(),
6618            dir.path().file_name().and_then(|n| n.to_str())
6619        );
6620        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6621        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6622        let head = repo.head().unwrap().target().unwrap();
6623        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6624    }
6625
6626    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6627
6628    #[test]
6629    fn git_status_head_sha_tracks_new_commits() {
6630        // The regression #1337 turns on: a commit must change the status the
6631        // snapshot is built from. Before the SHA rode the payload, committing
6632        // changed nothing on the wire, the server's diff dropped the identical
6633        // snapshot, and no client re-rendered — so a badge computed for the old
6634        // head survived the push that invalidated it.
6635        let dir = tempfile::tempdir().unwrap();
6636        let repo = init_repo(dir.path());
6637        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6638        repo.set_head("refs/heads/main").unwrap();
6639        let before = git_status_cheap(dir.path());
6640        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6641
6642        let head = repo.find_commit(a).unwrap();
6643        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6644        let after = git_status_cheap(dir.path());
6645        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6646        assert_ne!(before.head_sha, after.head_sha);
6647        // The branch is unchanged — the SHA is the *only* thing that moved, which
6648        // is exactly why its absence made the push invisible.
6649        assert_eq!(before.branch, after.branch);
6650    }
6651
6652    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6653
6654    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6655    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6656    /// these tests exercise a push with no network and no second repo.
6657    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6658        repo.reference("refs/remotes/origin/main", oid, true, "push")
6659            .unwrap();
6660    }
6661
6662    #[test]
6663    fn git_status_upstream_sha_tracks_a_push() {
6664        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6665        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6666        // remote-tracking ref, so before this field rode the payload every wire
6667        // field was byte-identical across the push, the server's diff dropped the
6668        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6669        // never re-asked — the row showed `↑1 ↓0` forever.
6670        let dir = tempfile::tempdir().unwrap();
6671        let repo = diverging_repo(dir.path());
6672        let before = git_status(dir.path());
6673        assert_eq!(before.ahead, Some(1));
6674        assert_eq!(before.behind, Some(1));
6675
6676        let head = repo.head().unwrap().target().unwrap();
6677        simulate_push(&repo, head);
6678        let after = git_status(dir.path());
6679
6680        // The upstream now names the pushed commit, and the counts agree.
6681        assert_eq!(
6682            after.upstream_sha.as_deref(),
6683            Some(head.to_string().as_str())
6684        );
6685        assert_ne!(before.upstream_sha, after.upstream_sha);
6686        assert_eq!(after.ahead, Some(0));
6687        assert_eq!(after.behind, Some(0));
6688        // Nothing else moved — which is the whole point. A push leaves the branch
6689        // and the local head exactly where they were, so `upstream_sha` is the
6690        // only signal a client could possibly notice.
6691        assert_eq!(before.branch, after.branch);
6692        assert_eq!(before.head_sha, after.head_sha);
6693    }
6694
6695    #[test]
6696    fn git_status_cheap_reports_upstream_sha() {
6697        // The crux: the field has to ride the *cheap* path, since that is the one
6698        // the streamed snapshot is built from. Costing a config lookup and a refs
6699        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6700        // walk still absent here.
6701        let dir = tempfile::tempdir().unwrap();
6702        let repo = diverging_repo(dir.path());
6703        let status = git_status_cheap(dir.path());
6704        let upstream = repo
6705            .find_branch("origin/main", git2::BranchType::Remote)
6706            .unwrap()
6707            .get()
6708            .target()
6709            .unwrap();
6710        assert_eq!(
6711            status.upstream_sha.as_deref(),
6712            Some(upstream.to_string().as_str())
6713        );
6714        assert_eq!(status.ahead, None);
6715        assert_eq!(status.behind, None);
6716    }
6717
6718    #[test]
6719    fn folder_ahead_behind_computes_divergence_and_degrades() {
6720        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6721        let dir = tempfile::tempdir().unwrap();
6722        let _repo = diverging_repo(dir.path());
6723        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6724
6725        // A branch with no upstream → None (the tree renders no sync indicator).
6726        let no_up = tempfile::tempdir().unwrap();
6727        let repo = init_repo(no_up.path());
6728        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6729        repo.set_head("refs/heads/main").unwrap();
6730        assert_eq!(folder_ahead_behind(no_up.path()), None);
6731
6732        // A detached HEAD and a plain (non-repo) directory → None.
6733        let detached = tempfile::tempdir().unwrap();
6734        let drepo = init_repo(detached.path());
6735        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6736        drepo.set_head_detached(a).unwrap();
6737        assert_eq!(folder_ahead_behind(detached.path()), None);
6738        let plain = tempfile::tempdir().unwrap();
6739        assert_eq!(folder_ahead_behind(plain.path()), None);
6740    }
6741
6742    // --- Lazy main-branch behind (#1457) ------------------------------------
6743
6744    #[test]
6745    fn folder_main_behind_computes_divergence_and_degrades() {
6746        // No own upstream, but a resolvable `origin/main` (via the common-names
6747        // fallback — no `origin/HEAD` symref is set) that the branch is
6748        // genuinely behind.
6749        let dir = tempfile::tempdir().unwrap();
6750        let _repo = behind_main_no_upstream_repo(dir.path());
6751        assert_eq!(folder_main_behind(dir.path()), Some(1));
6752
6753        // A detached HEAD and a plain (non-repo) directory → None.
6754        let detached = tempfile::tempdir().unwrap();
6755        let drepo = init_repo(detached.path());
6756        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6757        drepo.set_head_detached(a).unwrap();
6758        assert_eq!(folder_main_behind(detached.path()), None);
6759        let plain = tempfile::tempdir().unwrap();
6760        assert_eq!(folder_main_behind(plain.path()), None);
6761    }
6762
6763    #[test]
6764    fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6765        // `diverging_repo` checks out `main` tracking `origin/main` itself — the
6766        // common case — so even though it's genuinely 1 behind, `folder_main_behind`
6767        // stays silent: `folder_ahead_behind`'s `behind` already reports this
6768        // exact divergence.
6769        let dir = tempfile::tempdir().unwrap();
6770        let _repo = diverging_repo(dir.path());
6771        assert_eq!(folder_main_behind(dir.path()), None);
6772    }
6773
6774    #[test]
6775    fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6776        // No `origin` remote-tracking refs at all (no symref, no common names).
6777        let dir = tempfile::tempdir().unwrap();
6778        let repo = init_repo(dir.path());
6779        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6780        repo.set_head("refs/heads/main").unwrap();
6781        assert_eq!(folder_main_behind(dir.path()), None);
6782    }
6783
6784    #[test]
6785    fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6786        let dir = tempfile::tempdir().unwrap();
6787        let repo = init_repo(dir.path());
6788        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6789        let base_commit = repo.find_commit(base).unwrap();
6790
6791        // origin/main advances 3 commits past the shared base.
6792        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6793        let m1_commit = repo.find_commit(m1).unwrap();
6794        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6795        let m2_commit = repo.find_commit(m2).unwrap();
6796        let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6797        repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6798            .unwrap();
6799
6800        // `feature` branches off the shared base and diverges 1 ahead / 1
6801        // behind its own upstream `origin/feature`.
6802        let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6803        repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6804            .unwrap();
6805        empty_commit(
6806            &repo,
6807            Some("refs/heads/feature"),
6808            &[&base_commit],
6809            "local-feature",
6810        );
6811        drop(base_commit);
6812        drop(m1_commit);
6813        drop(m2_commit);
6814
6815        repo.set_head("refs/heads/feature").unwrap();
6816        let mut cfg = repo.config().unwrap();
6817        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6818            .unwrap();
6819        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6820            .unwrap();
6821        cfg.set_str("branch.feature.remote", "origin").unwrap();
6822        cfg.set_str("branch.feature.merge", "refs/heads/feature")
6823            .unwrap();
6824
6825        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6826        assert_eq!(folder_main_behind(dir.path()), Some(3));
6827    }
6828
6829    #[tokio::test]
6830    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6831        let diverging = tempfile::tempdir().unwrap();
6832        let _d = diverging_repo(diverging.path());
6833        let no_up = tempfile::tempdir().unwrap();
6834        let repo = init_repo(no_up.path());
6835        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6836        repo.set_head("refs/heads/main").unwrap();
6837
6838        let svc = WorktreesService::new();
6839        let diverging_path = diverging.path().display().to_string();
6840        let no_up_path = no_up.path().display().to_string();
6841        let reply = svc
6842            .handle(
6843                "ahead-behind",
6844                json!({ "paths": [&diverging_path, &no_up_path] }),
6845            )
6846            .await
6847            .unwrap();
6848        let results = reply.get("results").unwrap();
6849        // The diverging worktree carries its counts, keyed by the requested path.
6850        // `diverging_repo` checks out `main` tracking `origin/main` itself, so
6851        // `main_behind` is skipped — `behind` already reports this divergence.
6852        let d = results.get(diverging_path.as_str()).unwrap();
6853        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6854        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6855        assert!(d.get("main_behind").is_none(), "{d:?}");
6856        // The no-upstream worktree has no `origin` remote-tracking refs at all,
6857        // so neither `ahead`/`behind` nor `main_behind` resolves — the row is
6858        // omitted entirely, not reported as zero.
6859        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6860
6861        // A missing/empty `paths` list yields an empty results object, not an error.
6862        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6863        assert_eq!(empty.get("results"), Some(&json!({})));
6864    }
6865
6866    #[tokio::test]
6867    async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6868        let dir = tempfile::tempdir().unwrap();
6869        let _repo = behind_main_no_upstream_repo(dir.path());
6870        let svc = WorktreesService::new();
6871        let path = dir.path().display().to_string();
6872        let reply = svc
6873            .handle("ahead-behind", json!({ "paths": [&path] }))
6874            .await
6875            .unwrap();
6876        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6877        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6878        // No own upstream at all → no `ahead`/`behind` keys, just `main_behind`.
6879        assert!(entry.get("ahead").is_none(), "{entry:?}");
6880        assert!(entry.get("behind").is_none(), "{entry:?}");
6881    }
6882
6883    #[tokio::test]
6884    async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6885        // `release` stays perfectly in sync with its own upstream
6886        // `origin/release`, while `origin/main` has independently advanced 2
6887        // commits past their shared base — `main_behind` must still fold in
6888        // even though `ahead`/`behind` are both zero.
6889        let dir = tempfile::tempdir().unwrap();
6890        let repo = init_repo(dir.path());
6891        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6892        let base_commit = repo.find_commit(base).unwrap();
6893
6894        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6895        let m1_commit = repo.find_commit(m1).unwrap();
6896        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6897        repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6898            .unwrap();
6899
6900        let r = empty_commit(
6901            &repo,
6902            Some("refs/heads/release"),
6903            &[&base_commit],
6904            "release",
6905        );
6906        repo.reference("refs/remotes/origin/release", r, true, "origin release")
6907            .unwrap();
6908        drop(base_commit);
6909        drop(m1_commit);
6910
6911        repo.set_head("refs/heads/release").unwrap();
6912        let mut cfg = repo.config().unwrap();
6913        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6914            .unwrap();
6915        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6916            .unwrap();
6917        cfg.set_str("branch.release.remote", "origin").unwrap();
6918        cfg.set_str("branch.release.merge", "refs/heads/release")
6919            .unwrap();
6920
6921        let svc = WorktreesService::new();
6922        let path = dir.path().display().to_string();
6923        let reply = svc
6924            .handle("ahead-behind", json!({ "paths": [&path] }))
6925            .await
6926            .unwrap();
6927        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6928        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6929        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6930        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6931    }
6932
6933    #[tokio::test]
6934    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6935        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
6936        let dir = tempfile::tempdir().unwrap();
6937        let _repo = diverging_repo(dir.path());
6938        let svc = WorktreesService::new();
6939        svc.handle(
6940            "register",
6941            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6942        )
6943        .await
6944        .unwrap();
6945
6946        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6947        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6948        let main_wt = &worktrees[0];
6949        // The cheap parts are present, but divergence is not — it is fetched
6950        // lazily via the `ahead-behind` op (#1306).
6951        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6952        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6953        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6954    }
6955
6956    #[tokio::test]
6957    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6958        // The end-to-end shape of the #1337 freshness fix. The server pushes a
6959        // snapshot only when it differs from the last one
6960        // (`server.rs`: `if snap != last`), so anything invisible on the wire
6961        // cannot trigger a re-render. Committing must therefore move the payload.
6962        let dir = tempfile::tempdir().unwrap();
6963        let repo = init_repo(dir.path());
6964        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6965        repo.set_head("refs/heads/main").unwrap();
6966
6967        let svc = WorktreesService::new();
6968        svc.handle(
6969            "register",
6970            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6971        )
6972        .await
6973        .unwrap();
6974
6975        let before = svc.handle("tree", Value::Null).await.unwrap();
6976        let wt = &repos_of(&before)[0]["worktrees"][0];
6977        assert_eq!(
6978            wt.get("head_sha").and_then(Value::as_str),
6979            Some(a.to_string().as_str())
6980        );
6981
6982        // Commit again: same branch, same paths, same open windows — pre-#1337 the
6983        // snapshot was byte-identical here and the push was dropped.
6984        let head = repo.find_commit(a).unwrap();
6985        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6986        let after = svc.handle("tree", Value::Null).await.unwrap();
6987        assert_eq!(
6988            repos_of(&after)[0]["worktrees"][0]
6989                .get("head_sha")
6990                .and_then(Value::as_str),
6991            Some(b.to_string().as_str())
6992        );
6993        assert_ne!(before, after, "a commit must be a visible snapshot delta");
6994    }
6995
6996    #[tokio::test]
6997    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6998        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
6999        // matching the payload's `skip_serializing_if` convention.
7000        let dir = tempfile::tempdir().unwrap();
7001        init_repo(dir.path());
7002        let svc = WorktreesService::new();
7003        svc.handle(
7004            "register",
7005            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7006        )
7007        .await
7008        .unwrap();
7009        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7010        assert!(wt.get("head_sha").is_none(), "{wt:?}");
7011    }
7012
7013    // --- Upstream SHA on the snapshot (#1344) ------------------------------
7014
7015    #[tokio::test]
7016    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7017        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
7018        // moves neither the branch nor the local head, so `upstream_sha` is the
7019        // only field that can carry the news. Without it the snapshot serialised
7020        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
7021        // window re-rendered, and the lazy ahead/behind was never re-fetched.
7022        let dir = tempfile::tempdir().unwrap();
7023        let repo = diverging_repo(dir.path());
7024        let svc = WorktreesService::new();
7025        svc.handle(
7026            "register",
7027            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7028        )
7029        .await
7030        .unwrap();
7031
7032        let before = svc.handle("tree", Value::Null).await.unwrap();
7033        let head = repo.head().unwrap().target().unwrap();
7034        assert_ne!(
7035            repos_of(&before)[0]["worktrees"][0]
7036                .get("upstream_sha")
7037                .and_then(Value::as_str),
7038            Some(head.to_string().as_str()),
7039            "the fixture must start un-pushed for this to prove anything"
7040        );
7041
7042        // Push: only `refs/remotes/origin/main` moves.
7043        simulate_push(&repo, head);
7044        let after = svc.handle("tree", Value::Null).await.unwrap();
7045        let wt = &repos_of(&after)[0]["worktrees"][0];
7046        assert_eq!(
7047            wt.get("upstream_sha").and_then(Value::as_str),
7048            Some(head.to_string().as_str())
7049        );
7050        // The head and branch are untouched across the push — so this delta rests
7051        // entirely on `upstream_sha`.
7052        assert_eq!(
7053            wt.get("head_sha").and_then(Value::as_str),
7054            repos_of(&before)[0]["worktrees"][0]
7055                .get("head_sha")
7056                .and_then(Value::as_str)
7057        );
7058        assert_ne!(before, after, "a push must be a visible snapshot delta");
7059    }
7060
7061    #[tokio::test]
7062    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7063        // Wire-compat, and the no-regression case: a branch tracking nothing sends
7064        // no key at all rather than a null, so an older client sees exactly the
7065        // payload it saw before and still renders no sync indicator.
7066        let dir = tempfile::tempdir().unwrap();
7067        let repo = init_repo(dir.path());
7068        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7069        repo.set_head("refs/heads/main").unwrap();
7070        let svc = WorktreesService::new();
7071        svc.handle(
7072            "register",
7073            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7074        )
7075        .await
7076        .unwrap();
7077        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7078        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7079        // The head still rides, so this is specifically the upstream degrading.
7080        assert!(wt.get("head_sha").is_some(), "{wt:?}");
7081    }
7082
7083    // --- PR badge poller (#1337) -------------------------------------------
7084
7085    /// Writes an executable stub that ignores its arguments and prints `stdout`,
7086    /// standing in for `gh api graphql` so the poll loop is exercised offline.
7087    /// Returns the shim lock alongside the path: the caller **must** hold the
7088    /// guard until the poller has finished exec'ing the stub. Writing an
7089    /// executable and then `execve`ing it races every other thread that forks —
7090    /// the child inherits the still-open writable FD and the exec fails
7091    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
7092    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7093        let guard = shim_lock();
7094        let path = dir.join("fake-gh");
7095        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7096        (path, guard)
7097    }
7098
7099    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
7100    /// byte to a counter file before printing `stdout`. The returned counter path
7101    /// lets a test assert how many `gh` subprocesses actually ran, independent of
7102    /// the #1387 request-log counter — so the two can be compared (#1389).
7103    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7104        let guard = shim_lock();
7105        let path = dir.join("fake-gh");
7106        let counter = dir.join("gh-calls");
7107        write_exec_script(
7108            &path,
7109            &format!(
7110                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7111                counter = counter.display()
7112            ),
7113        );
7114        (path, guard, counter)
7115    }
7116
7117    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
7118    /// ran) — the length of the counter file.
7119    fn gh_spawn_count(counter: &Path) -> usize {
7120        std::fs::read(counter).map_or(0, |b| b.len())
7121    }
7122
7123    /// The number of **successful** `kind: "gh"` records the #1387 choke point
7124    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
7125    /// on the exit code matches the ground-truth counter (which is written when the
7126    /// stub *runs*), so a rare failed spawn cannot desync the two.
7127    fn counted_gh_records(log: &Path) -> usize {
7128        std::fs::read_to_string(log)
7129            .unwrap_or_default()
7130            .lines()
7131            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7132            .count()
7133    }
7134
7135    /// A repo with a GitHub origin and one commit on `main`.
7136    fn github_repo(dir: &Path) -> Repository {
7137        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7138    }
7139
7140    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
7141    /// test can register **distinct** targets (owner/name) across windows.
7142    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7143        let repo = init_repo(dir);
7144        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7145        repo.set_head("refs/heads/main").unwrap();
7146        repo.remote("origin", url).unwrap();
7147        repo
7148    }
7149
7150    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
7151    /// because the fold downgrades a badge naming a different commit than the
7152    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
7153    /// wrong reason.
7154    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7155        PrBadge {
7156            number,
7157            is_draft: false,
7158            checks: PrCheckState::Pending,
7159            url: "u".into(),
7160            head_oid: head_oid.to_string(),
7161        }
7162    }
7163
7164    /// Wraps a badge as the cache's resolution value (#1370).
7165    fn pr(badge: PrBadge) -> PrResolution {
7166        PrResolution::Pr(badge)
7167    }
7168
7169    #[test]
7170    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7171        let snapshot = json!({"repos":[
7172            {
7173                "main_repo":"omni-dev",
7174                "github":{"owner":"rust-works","name":"omni-dev"},
7175                "root":"/r",
7176                // Enabled (#1376) — a not-polled repo contributes no targets (see
7177                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
7178                "polling_enabled":true,
7179                // Two worktrees on the same branch must ask once, not twice.
7180                "worktrees":[
7181                    {"path":"/r","branch":"main","is_main":true,"open":true},
7182                    {"path":"/w1","branch":"main","is_main":false,"open":true},
7183                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
7184                    // Detached: no branch, so nothing to resolve.
7185                    {"path":"/w3","is_main":false,"open":true}
7186                ]
7187            },
7188            {
7189                // Not on GitHub: contributes no targets at all.
7190                "main_repo":"local","root":"/l",
7191                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7192            }
7193        ]});
7194        let targets = pr_targets_from_snapshot(&snapshot);
7195        assert_eq!(
7196            targets,
7197            vec![
7198                PrTarget {
7199                    owner: "rust-works".into(),
7200                    name: "omni-dev".into(),
7201                    branch: "feature".into()
7202                },
7203                PrTarget {
7204                    owner: "rust-works".into(),
7205                    name: "omni-dev".into(),
7206                    branch: "main".into()
7207                },
7208            ]
7209        );
7210    }
7211
7212    #[test]
7213    fn pr_targets_from_snapshot_is_empty_without_repos() {
7214        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7215        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7216    }
7217
7218    #[test]
7219    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7220        // Defensive: a `github` object without usable owner/name strings yields no
7221        // target rather than a half-built query.
7222        for github in [
7223            json!({}),
7224            json!({"owner": "o"}),
7225            json!({"owner": 1, "name": 2}),
7226        ] {
7227            let snapshot = json!({"repos":[{
7228                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7229                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7230            }]});
7231            assert!(
7232                pr_targets_from_snapshot(&snapshot).is_empty(),
7233                "{snapshot:?}"
7234            );
7235        }
7236    }
7237
7238    #[test]
7239    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7240        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
7241        // absent (the default-off case) or explicitly false contributes no watch,
7242        // so the poll never mentions it. Only an enabled repo is polled.
7243        for repo in [
7244            json!({
7245                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7246                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7247            }),
7248            json!({
7249                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7250                "polling_enabled":false,
7251                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7252            }),
7253        ] {
7254            let snapshot = json!({ "repos": [repo] });
7255            assert!(
7256                pr_targets_from_snapshot(&snapshot).is_empty(),
7257                "not-polled repo must yield no targets: {snapshot:?}"
7258            );
7259        }
7260        // Flipping the same repo to enabled makes it contribute.
7261        let enabled = json!({"repos":[{
7262            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7263            "polling_enabled":true,
7264            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7265        }]});
7266        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7267    }
7268
7269    #[test]
7270    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7271        let backoff = Duration::from_secs(600);
7272        // Never fetched: go.
7273        assert!(pr_should_fetch(false, None, backoff));
7274        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
7275        // spend nothing.
7276        assert!(!pr_should_fetch(
7277            false,
7278            Some(Duration::from_secs(1)),
7279            backoff
7280        ));
7281        // Quiet tree, backoff elapsed: time to look again.
7282        assert!(pr_should_fetch(false, Some(backoff), backoff));
7283        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7284        // The load-bearing case: the watch grew (a target added, or an upstream
7285        // pushed), so fetch **now** regardless of how deep the backoff had grown.
7286        // Without this a push waits out the full ceiling on a stale badge.
7287        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7288        assert!(pr_should_fetch(
7289            true,
7290            Some(Duration::from_millis(1)),
7291            backoff
7292        ));
7293    }
7294
7295    #[test]
7296    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7297        let base = Duration::from_secs(10);
7298        let fresh = Some(Duration::ZERO);
7299        let stale = Some(PENDING_FAST_WINDOW);
7300        // Pending and fresh (within the fast window): hold `base`, however long we
7301        // had backed off for.
7302        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7303        assert_eq!(
7304            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7305            base
7306        );
7307        // Pending but past the fast window (a long CI run): escalate — double up to
7308        // the pending ceiling, never to the terminal one.
7309        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7310        assert_eq!(
7311            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7312            PENDING_MAX_INTERVAL
7313        );
7314        // Pending with nothing having moved yet (`None`) is treated as past the fast
7315        // window, so a stale-from-boot pending state does not pin `base`.
7316        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7317        // Everything terminal: double…
7318        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7319        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7320        // …up to the terminal ceiling (above the pending one), and never overflow.
7321        assert_eq!(
7322            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7323            MAX_PR_POLL_INTERVAL
7324        );
7325        assert_eq!(
7326            next_pr_poll_delay(Duration::MAX, base, false, None),
7327            MAX_PR_POLL_INTERVAL
7328        );
7329    }
7330
7331    /// A watch on one branch with the given upstream tip.
7332    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7333        PrWatch {
7334            target: PrTarget {
7335                owner: "rust-works".into(),
7336                name: "omni-dev".into(),
7337                branch: branch.into(),
7338            },
7339            upstream_sha: upstream.map(str::to_string),
7340        }
7341    }
7342
7343    #[test]
7344    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7345        let a = watch("a", Some("111"));
7346        let b = watch("b", Some("222"));
7347        let ab = [a.clone(), b.clone()];
7348        let just_a = std::slice::from_ref(&a);
7349        let just_b = std::slice::from_ref(&b);
7350        // Nothing new: quiet tick.
7351        assert!(!pr_watch_grew(&ab, &ab));
7352        // Addition: a new target appeared.
7353        assert!(pr_watch_grew(just_a, &ab));
7354        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
7355        assert!(!pr_watch_grew(&ab, just_a));
7356        // First sight (empty prev, from `None`): everything is new.
7357        assert!(pr_watch_grew(&[], just_a));
7358        // A push moves only the upstream — still "grew".
7359        let a_pushed = [watch("a", Some("999"))];
7360        assert!(pr_watch_grew(just_a, &a_pushed));
7361        // Gaining a target while losing another still fetches (the gain wins).
7362        assert!(pr_watch_grew(just_a, just_b));
7363    }
7364
7365    #[test]
7366    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7367        let base = Duration::from_secs(10);
7368        let over = RateLimitSnapshot {
7369            graphql: Some(rl_resource(90)),
7370            core: Some(rl_resource(3)),
7371            search: None,
7372        };
7373        let under = RateLimitSnapshot {
7374            graphql: Some(rl_resource(50)),
7375            core: Some(rl_resource(3)),
7376            search: None,
7377        };
7378        // No reading yet: unchanged.
7379        assert_eq!(budget_throttled_delay(base, None), base);
7380        // Under the warn threshold: unchanged.
7381        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7382        // Over: raised to at least the throttle floor…
7383        assert_eq!(
7384            budget_throttled_delay(base, Some(&over)),
7385            BUDGET_THROTTLE_INTERVAL
7386        );
7387        // …but a delay already above the floor is left alone (never shortened).
7388        let long = BUDGET_THROTTLE_INTERVAL * 2;
7389        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7390    }
7391
7392    #[test]
7393    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7394        // The persisted cache must survive a JSON round trip with `head_oid` intact
7395        // — it is the staleness key the tree wire drops, and losing it would render
7396        // every restored badge stale (#1389, fix 4).
7397        let target = PrTarget {
7398            owner: "rust-works".into(),
7399            name: "omni-dev".into(),
7400            branch: "main".into(),
7401        };
7402        let badge = PrResolution::Pr(PrBadge {
7403            number: 1337,
7404            is_draft: true,
7405            checks: PrCheckState::Pending,
7406            url: "http://x/1337".into(),
7407            head_oid: "deadbeef".into(),
7408        });
7409        let watched = vec![watch("main", Some("abc"))];
7410        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7411            .unwrap()
7412            .with_timezone(&Utc);
7413        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7414
7415        let json = serde_json::to_vec(&prefs).unwrap();
7416        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7417        assert_eq!(back, prefs);
7418        assert_eq!(back.polled_at, Some(polled_at));
7419        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7420        // The restored resolution equals the original — head_oid and all.
7421        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7422    }
7423
7424    #[test]
7425    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7426        // The explicit negative must survive persistence too: restoring `NoPr`
7427        // as "absent" would lose the #1370 distinction across a restart and
7428        // re-ask GitHub for branches already known to have no PR.
7429        let target = PrTarget {
7430            owner: "rust-works".into(),
7431            name: "omni-dev".into(),
7432            branch: "feature".into(),
7433        };
7434        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7435        let json = serde_json::to_vec(&prefs).unwrap();
7436        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7437        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7438        assert_eq!(
7439            back.entries[0].resolution.clone().into_resolution(),
7440            PrResolution::NoPr
7441        );
7442    }
7443
7444    #[test]
7445    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7446        // A file with verdicts but no `polled_at` (an older shape, or a
7447        // hand-edited one) still renders badges, but must not arm the warm
7448        // start: without a poll time there is nothing to age the verdicts
7449        // against, so the poller re-polls immediately (#1389, fix 4).
7450        let dir = tempfile::tempdir().unwrap();
7451        let path = dir.path().join("pr-cache.json");
7452        let target = PrTarget {
7453            owner: "rust-works".into(),
7454            name: "omni-dev".into(),
7455            branch: "main".into(),
7456        };
7457        let mut prefs = pr_cache_prefs_from(
7458            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7459            &[watch("main", None)],
7460            Utc::now(),
7461        );
7462        prefs.polled_at = None;
7463        write_pr_cache(&path, &prefs).unwrap();
7464
7465        let svc = WorktreesService::new();
7466        svc.load_pr_cache(path);
7467        assert!(
7468            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7469            "the badge itself must still restore"
7470        );
7471        assert!(
7472            svc.pr_warm_start
7473                .lock()
7474                .unwrap_or_else(PoisonError::into_inner)
7475                .is_none(),
7476            "no poll time means a cold start, not a trusted warm one"
7477        );
7478    }
7479
7480    /// Installs a thread-local WARN-level subscriber for the duration of a
7481    /// test, so degraded-path `tracing::warn!` sites actually format their
7482    /// fields instead of short-circuiting on "nobody is listening".
7483    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7484        tracing::subscriber::set_default(
7485            tracing_subscriber::fmt()
7486                .with_max_level(tracing::Level::WARN)
7487                .with_writer(std::io::sink)
7488                .finish(),
7489        )
7490    }
7491
7492    #[test]
7493    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7494        // The best-effort contract: a mangled cache is logged and treated as
7495        // empty — never a panic — and the path is stored regardless, so the
7496        // next successful poll rewrites a clean file (#1389, fix 4).
7497        let _trace = warn_subscriber();
7498        let dir = tempfile::tempdir().unwrap();
7499        let corrupt = dir.path().join("pr-cache.json");
7500        std::fs::write(&corrupt, b"not json").unwrap();
7501        let svc = WorktreesService::new();
7502        svc.load_pr_cache(corrupt.clone());
7503        assert!(svc.pr_cache.entries().is_empty());
7504        assert_eq!(
7505            svc.pr_cache_path
7506                .lock()
7507                .unwrap_or_else(PoisonError::into_inner)
7508                .as_deref(),
7509            Some(corrupt.as_path()),
7510            "the path must be stored even when the load fails, so persistence recovers"
7511        );
7512
7513        // A directory: `read` fails with a non-NotFound error (the distinct
7514        // "could not read" arm), with the same treated-as-empty outcome.
7515        let svc = WorktreesService::new();
7516        svc.load_pr_cache(dir.path().to_path_buf());
7517        assert!(svc.pr_cache.entries().is_empty());
7518    }
7519
7520    #[test]
7521    fn persist_pr_cache_swallows_a_write_failure() {
7522        // Best-effort: an unwritable path is logged at WARN and swallowed — the
7523        // in-memory cache stays authoritative, and losing the warm start only
7524        // costs one extra poll after the next restart.
7525        let _trace = warn_subscriber();
7526        let dir = tempfile::tempdir().unwrap();
7527        let blocker = dir.path().join("blocker");
7528        std::fs::write(&blocker, b"").unwrap();
7529        // The parent is a regular file, so creating the runtime dir must fail.
7530        let path = blocker.join("pr-cache.json");
7531        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7532        assert!(!path.exists());
7533        // The root has no parent at all — nothing to create, and the write
7534        // itself fails (it is a directory); still swallowed.
7535        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7536    }
7537
7538    #[tokio::test]
7539    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7540        let dir = tempfile::tempdir().unwrap();
7541        let repo = github_repo(dir.path());
7542        let head = repo.head().unwrap().target().unwrap().to_string();
7543        let svc = WorktreesService::new();
7544        svc.handle(
7545            "register",
7546            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7547        )
7548        .await
7549        .unwrap();
7550        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7551        // act on it, as if the user had toggled it on.
7552        svc.registry.set_polling("rust-works", "omni-dev", true);
7553
7554        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
7555        // and so is the negative, so "not resolved" stays distinguishable (#1370).
7556        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7557        assert!(wt.get("pr").is_none(), "{wt:?}");
7558        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7559
7560        // Seed the cache the poller writes, then re-read the tree.
7561        let mut badges = HashMap::new();
7562        badges.insert(
7563            PrTarget {
7564                owner: "rust-works".into(),
7565                name: "omni-dev".into(),
7566                branch: "main".into(),
7567            },
7568            pr(pending_badge(1337, &head)),
7569        );
7570        assert!(svc.pr_cache.replace(badges));
7571
7572        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7573        assert_eq!(wt["pr"]["number"], json!(1337));
7574        assert_eq!(wt["pr"]["checks"], json!("pending"));
7575        // camelCase on the wire, or the extension silently loses the draft marker.
7576        assert_eq!(wt["pr"]["isDraft"], json!(false));
7577        // A badge and the negative are mutually exclusive.
7578        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7579    }
7580
7581    #[tokio::test]
7582    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7583        // A detached HEAD has a commit but no branch, and a badge is keyed by
7584        // branch — so there is nothing to match. It must fall through silently
7585        // rather than borrow a badge from whatever branch happens to be cached, and
7586        // rather than sink the tree.
7587        let dir = tempfile::tempdir().unwrap();
7588        let repo = github_repo(dir.path());
7589        let head = repo.head().unwrap().target().unwrap();
7590        repo.set_head_detached(head).unwrap();
7591
7592        let svc = WorktreesService::new();
7593        svc.handle(
7594            "register",
7595            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7596        )
7597        .await
7598        .unwrap();
7599        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7600        // act on it, as if the user had toggled it on.
7601        svc.registry.set_polling("rust-works", "omni-dev", true);
7602        // A badge *is* cached for `main` — the branch this worktree was on before
7603        // detaching. It must not leak onto the now-branchless row.
7604        let mut badges = HashMap::new();
7605        badges.insert(
7606            PrTarget {
7607                owner: "rust-works".into(),
7608                name: "omni-dev".into(),
7609                branch: "main".into(),
7610            },
7611            pr(pending_badge(1, &head.to_string())),
7612        );
7613        svc.pr_cache.replace(badges);
7614
7615        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7616        assert!(wt.get("branch").is_none(), "{wt:?}");
7617        // The SHA still shows — detached means no branch, not no commit.
7618        assert_eq!(
7619            wt.get("head_sha").and_then(Value::as_str),
7620            Some(head.to_string().as_str())
7621        );
7622        assert!(wt.get("pr").is_none(), "{wt:?}");
7623        // No branch means nothing was checked either: no negative on the row.
7624        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7625    }
7626
7627    #[tokio::test]
7628    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7629        let dir = tempfile::tempdir().unwrap();
7630        github_repo(dir.path());
7631        let svc = WorktreesService::new();
7632        svc.handle(
7633            "register",
7634            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7635        )
7636        .await
7637        .unwrap();
7638        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7639        // act on it, as if the user had toggled it on.
7640        svc.registry.set_polling("rust-works", "omni-dev", true);
7641        // A badge for a different branch must not leak onto `main`.
7642        let mut badges = HashMap::new();
7643        badges.insert(
7644            PrTarget {
7645                owner: "rust-works".into(),
7646                name: "omni-dev".into(),
7647                branch: "other".into(),
7648            },
7649            pr(pending_badge(1, "irrelevant")),
7650        );
7651        svc.pr_cache.replace(badges);
7652        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7653        assert!(wt.get("pr").is_none(), "{wt:?}");
7654        // An unmatched branch is unresolved, not negative.
7655        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7656    }
7657
7658    #[tokio::test]
7659    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7660        // The #1370 fix on the wire: a branch the poller checked and found PR-less
7661        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
7662        // tell "checked, none" from "not resolved" and keep its fallback quiet.
7663        let dir = tempfile::tempdir().unwrap();
7664        github_repo(dir.path());
7665        let svc = WorktreesService::new();
7666        svc.handle(
7667            "register",
7668            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7669        )
7670        .await
7671        .unwrap();
7672        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7673        // act on it, as if the user had toggled it on.
7674        svc.registry.set_polling("rust-works", "omni-dev", true);
7675
7676        let mut resolutions = HashMap::new();
7677        resolutions.insert(
7678            PrTarget {
7679                owner: "rust-works".into(),
7680                name: "omni-dev".into(),
7681                branch: "main".into(),
7682            },
7683            PrResolution::NoPr,
7684        );
7685        assert!(svc.pr_cache.replace(resolutions));
7686
7687        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7688        assert_eq!(wt["pr_none"], json!(true));
7689        // Mutually exclusive with a badge.
7690        assert!(wt.get("pr").is_none(), "{wt:?}");
7691    }
7692
7693    #[tokio::test]
7694    async fn a_commit_does_not_drop_a_negative_resolution() {
7695        // A negative has no commit to be stale against. Dropping it when HEAD
7696        // moves would flip the row back to "unresolved" on every local commit —
7697        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
7698        // poller's `moved` trigger re-checks the branch promptly instead.
7699        let dir = tempfile::tempdir().unwrap();
7700        let repo = github_repo(dir.path());
7701        let first = repo.head().unwrap().target().unwrap();
7702
7703        let svc = WorktreesService::new();
7704        svc.handle(
7705            "register",
7706            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7707        )
7708        .await
7709        .unwrap();
7710        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7711        // act on it, as if the user had toggled it on.
7712        svc.registry.set_polling("rust-works", "omni-dev", true);
7713
7714        let mut resolutions = HashMap::new();
7715        resolutions.insert(
7716            PrTarget {
7717                owner: "rust-works".into(),
7718                name: "omni-dev".into(),
7719                branch: "main".into(),
7720            },
7721            PrResolution::NoPr,
7722        );
7723        svc.pr_cache.replace(resolutions);
7724
7725        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726        assert_eq!(wt["pr_none"], json!(true));
7727
7728        // Commit — as a push would leave things, with the cache untouched.
7729        let head = repo.find_commit(first).unwrap();
7730        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7731
7732        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7733        assert_eq!(
7734            wt["pr_none"],
7735            json!(true),
7736            "a local commit must not drop the negative"
7737        );
7738    }
7739
7740    #[tokio::test]
7741    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7742        // The idle case — the daemon runs all day with no editor open. Point it at a
7743        // stub that fails loudly if ever spawned: a poll here would both waste
7744        // GitHub budget and, on a real `gh`, wake the radio for nothing.
7745        let bin_dir = tempfile::tempdir().unwrap();
7746        let marker = bin_dir.path().join("spawned");
7747        let fake = bin_dir.path().join("fake-gh");
7748        std::fs::write(
7749            &fake,
7750            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7751        )
7752        .unwrap();
7753        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7754        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7755        std::fs::set_permissions(&fake, perms).unwrap();
7756
7757        let svc = WorktreesService::new();
7758        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7759        tokio::time::sleep(Duration::from_millis(200)).await;
7760        svc.shutdown().await;
7761        assert!(
7762            !marker.exists(),
7763            "the poller must not spawn gh with no windows registered"
7764        );
7765    }
7766
7767    #[tokio::test]
7768    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7769        // Badges are decoration: an unauthenticated or broken `gh` must never sink
7770        // the tree, and one bad poll must not blank rows that were fine a second ago.
7771        let dir = tempfile::tempdir().unwrap();
7772        let repo = github_repo(dir.path());
7773        let head = repo.head().unwrap().target().unwrap().to_string();
7774        let bin_dir = tempfile::tempdir().unwrap();
7775        let fake = bin_dir.path().join("fake-gh");
7776        // Exits non-zero, exactly as `gh` does without `gh auth login`.
7777        std::fs::write(
7778            &fake,
7779            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7780        )
7781        .unwrap();
7782        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7783        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7784        std::fs::set_permissions(&fake, perms).unwrap();
7785
7786        let svc = WorktreesService::new();
7787        svc.handle(
7788            "register",
7789            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7790        )
7791        .await
7792        .unwrap();
7793        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7794        // act on it, as if the user had toggled it on.
7795        svc.registry.set_polling("rust-works", "omni-dev", true);
7796        // Seed a badge as though an earlier poll had succeeded.
7797        let mut seeded = HashMap::new();
7798        seeded.insert(
7799            PrTarget {
7800                owner: "rust-works".into(),
7801                name: "omni-dev".into(),
7802                branch: "main".into(),
7803            },
7804            pr(pending_badge(7, &head)),
7805        );
7806        svc.pr_cache.replace(seeded);
7807
7808        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7809        tokio::time::sleep(Duration::from_millis(200)).await;
7810
7811        // The tree still serves, and the seeded badge survived the failing polls —
7812        // which also minted no false "no PR" negatives (#1370).
7813        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7814        assert_eq!(wt["pr"]["number"], json!(7));
7815        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7816        svc.shutdown().await;
7817    }
7818
7819    #[tokio::test]
7820    // The shim guard is deliberately held across the awaits below: it must span
7821    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
7822    // is against another test writing while this one forks. Safe here — only test
7823    // threads take it, never a task inside the runtime, so it cannot deadlock.
7824    // Scoped per-test rather than on the module, which would also silence the
7825    // registry lock's "never held across .await" invariant.
7826    #[allow(clippy::await_holding_lock)]
7827    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7828        // The normal startup order: the daemon starts at login, *before* any
7829        // editor. It therefore sees an empty tree and backs off to the 30-minute
7830        // ceiling — so unless a register wakes it, the first badge of the session
7831        // arrives up to half an hour after the window does, which reads as the
7832        // feature being broken rather than slow.
7833        let dir = tempfile::tempdir().unwrap();
7834        github_repo(dir.path());
7835        let bin_dir = tempfile::tempdir().unwrap();
7836        let (fake, _shim) = fake_gh(
7837            bin_dir.path(),
7838            r#"{"data":{"r0":{"b0":{
7839                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7840                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7841                ]}}},
7842                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7843            }}}}"#,
7844        );
7845
7846        let svc = WorktreesService::new();
7847        // Poller first, with nothing registered — it backs off on the empty tree.
7848        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7849        tokio::time::sleep(Duration::from_millis(150)).await;
7850
7851        // Now an editor opens.
7852        svc.handle(
7853            "register",
7854            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7855        )
7856        .await
7857        .unwrap();
7858        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7859        // act on it, as if the user had toggled it on.
7860        svc.registry.set_polling("rust-works", "omni-dev", true);
7861
7862        // The badge must follow promptly — the register wakes the loop out of its
7863        // backoff. The deadline is orders of magnitude below the ceiling, so this
7864        // fails on the bug rather than merely being slow.
7865        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7866            loop {
7867                if let Some(PrResolution::Pr(badge)) =
7868                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7869                {
7870                    return badge;
7871                }
7872                tokio::time::sleep(Duration::from_millis(25)).await;
7873            }
7874        })
7875        .await
7876        .expect("a window opening must wake the poller out of its idle backoff");
7877        assert_eq!(badge.number, 99);
7878        svc.shutdown().await;
7879    }
7880
7881    #[tokio::test]
7882    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7883        // The acceptance criterion: "pushing a new commit invalidates the badge
7884        // rather than leaving the previous head's verdict standing."
7885        //
7886        // The cache still holds the verdict for the *old* commit, and the poller may
7887        // have backed off for up to half an hour. So the fold — which runs on every
7888        // snapshot — has to notice on its own, with no network call.
7889        let dir = tempfile::tempdir().unwrap();
7890        let repo = github_repo(dir.path());
7891        let first = repo.head().unwrap().target().unwrap();
7892
7893        let svc = WorktreesService::new();
7894        svc.handle(
7895            "register",
7896            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7897        )
7898        .await
7899        .unwrap();
7900        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7901        // act on it, as if the user had toggled it on.
7902        svc.registry.set_polling("rust-works", "omni-dev", true);
7903
7904        // A green verdict, correctly describing the commit currently checked out.
7905        let mut badges = HashMap::new();
7906        badges.insert(
7907            PrTarget {
7908                owner: "rust-works".into(),
7909                name: "omni-dev".into(),
7910                branch: "main".into(),
7911            },
7912            pr(PrBadge {
7913                number: 1337,
7914                is_draft: false,
7915                checks: PrCheckState::Success,
7916                url: "u".into(),
7917                head_oid: first.to_string(),
7918            }),
7919        );
7920        svc.pr_cache.replace(badges);
7921
7922        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7923        assert_eq!(
7924            wt["pr"]["checks"],
7925            json!("success"),
7926            "green for its own commit"
7927        );
7928
7929        // Now commit — as a push would leave things, with the cache untouched.
7930        let head = repo.find_commit(first).unwrap();
7931        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7932
7933        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7934        assert_eq!(
7935            wt["pr"]["checks"],
7936            json!("pending"),
7937            "the previous commit's ✓ must not stand after a new commit"
7938        );
7939        // The PR itself is still shown — it is the *verdict* that is unknown, not
7940        // the PR.
7941        assert_eq!(wt["pr"]["number"], json!(1337));
7942    }
7943
7944    #[test]
7945    fn is_stale_for_compares_the_commit_the_verdict_describes() {
7946        let badge = pending_badge(1, "aaa");
7947        assert!(!badge.is_stale_for(Some("aaa")));
7948        assert!(badge.is_stale_for(Some("bbb")));
7949        // No local HEAD (unborn): nothing to compare against, so not stale.
7950        assert!(!badge.is_stale_for(None));
7951    }
7952
7953    #[test]
7954    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7955        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
7956        // it — so asking would return exactly the cached verdict, and the badge
7957        // stays correctly stale via `is_stale_for` with no network. So a snapshot
7958        // that differs *only* in `head_sha` must compare **equal** as a watch.
7959        let snap = |sha: &str| {
7960            json!({"repos":[{
7961                "main_repo":"omni-dev",
7962                "github":{"owner":"rust-works","name":"omni-dev"},
7963                "root":"/r",
7964                "polling_enabled":true,
7965                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7966            }]})
7967        };
7968        let before = pr_watch_from_snapshot(&snap("aaa"));
7969        let after = pr_watch_from_snapshot(&snap("bbb"));
7970        assert_eq!(before.len(), 1);
7971        assert_eq!(before[0].target, after[0].target);
7972        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
7973        assert_eq!(before, after);
7974        assert!(!pr_watch_grew(&before, &after));
7975    }
7976
7977    #[test]
7978    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7979        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
7980        // it moves no local head — so an upstream move alone must register as "go
7981        // and ask now", or the badge sits at `●` until the backoff elapses.
7982        let snap = |upstream: &str| {
7983            json!({"repos":[{
7984                "main_repo":"omni-dev",
7985                "github":{"owner":"rust-works","name":"omni-dev"},
7986                "root":"/r",
7987                "polling_enabled":true,
7988                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7989                              "upstream_sha":upstream,"is_main":true,"open":true}]
7990            }]})
7991        };
7992        let before = pr_watch_from_snapshot(&snap("aaa"));
7993        let after = pr_watch_from_snapshot(&snap("bbb"));
7994        // Same target, only the upstream moved — a genuine "grew" signal.
7995        assert_eq!(before.len(), 1);
7996        assert_eq!(before[0].target, after[0].target);
7997        assert_ne!(before, after);
7998        assert!(pr_watch_grew(&before, &after));
7999        // A quiet tick still asks nothing.
8000        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8001        assert!(!pr_watch_grew(
8002            &before,
8003            &pr_watch_from_snapshot(&snap("aaa"))
8004        ));
8005    }
8006
8007    #[test]
8008    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8009        // An older daemon — or any branch tracking nothing — simply sends no
8010        // `upstream_sha`, which reads as `None` rather than failing the poll.
8011        let snap = json!({"repos":[{
8012            "main_repo":"omni-dev",
8013            "github":{"owner":"rust-works","name":"omni-dev"},
8014            "root":"/r",
8015            "polling_enabled":true,
8016            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8017        }]});
8018        let watch = pr_watch_from_snapshot(&snap);
8019        assert_eq!(watch.len(), 1);
8020        assert_eq!(watch[0].upstream_sha, None);
8021    }
8022
8023    #[test]
8024    fn start_pr_poller_is_a_noop_outside_a_runtime() {
8025        let svc = WorktreesService::new();
8026        svc.start_pr_poller();
8027        assert!(svc
8028            .poller
8029            .lock()
8030            .unwrap_or_else(PoisonError::into_inner)
8031            .is_none());
8032    }
8033
8034    #[tokio::test]
8035    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8036        let svc = WorktreesService::new();
8037        svc.start_pr_poller_with(
8038            Duration::from_millis(50),
8039            Duration::from_millis(10),
8040            PathBuf::from("/bin/true"),
8041        );
8042        let token = svc
8043            .poller
8044            .lock()
8045            .unwrap_or_else(PoisonError::into_inner)
8046            .as_ref()
8047            .map(|t| t.token.clone())
8048            .expect("poller started");
8049
8050        // Cancel the live task, then start again: if `start` spawned a replacement
8051        // it would orphan this one, so the token staying cancelled proves it did not.
8052        token.cancel();
8053        svc.start_pr_poller_with(
8054            Duration::from_millis(50),
8055            Duration::from_millis(10),
8056            PathBuf::from("/bin/true"),
8057        );
8058        assert!(svc
8059            .poller
8060            .lock()
8061            .unwrap_or_else(PoisonError::into_inner)
8062            .as_ref()
8063            .is_some_and(|t| t.token.is_cancelled()));
8064
8065        svc.shutdown().await;
8066        assert!(svc
8067            .poller
8068            .lock()
8069            .unwrap_or_else(PoisonError::into_inner)
8070            .is_none());
8071    }
8072
8073    // --- Rate-limit monitor (#1375) ---
8074
8075    /// A resource at `used`% of a 100-request budget.
8076    fn rl_resource(used: u64) -> RateLimitResource {
8077        RateLimitResource {
8078            used,
8079            limit: 100,
8080            remaining: 100 - used,
8081            percent: used as f64,
8082            reset: 0,
8083        }
8084    }
8085
8086    #[test]
8087    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8088        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8089            graphql: Some(rl_resource(graphql)),
8090            core: Some(rl_resource(core)),
8091            search: None,
8092        };
8093        // First poll already over threshold → warn.
8094        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8095        // First poll below → no warn.
8096        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8097        // Crossing upward → warn.
8098        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8099        // Staying over → no repeat warn.
8100        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8101        // Recovering below → no warn.
8102        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8103        // A *different* resource crossing while the first recovers is still caught.
8104        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8105    }
8106
8107    #[test]
8108    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8109        let svc = WorktreesService::new();
8110        svc.start_rate_limit_poller();
8111        assert!(svc
8112            .rate_limit_poller
8113            .lock()
8114            .unwrap_or_else(PoisonError::into_inner)
8115            .is_none());
8116    }
8117
8118    #[tokio::test]
8119    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8120        let svc = WorktreesService::new();
8121        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8122        let token = svc
8123            .rate_limit_poller
8124            .lock()
8125            .unwrap_or_else(PoisonError::into_inner)
8126            .as_ref()
8127            .map(|t| t.token.clone())
8128            .expect("poller started");
8129
8130        // Cancel the live task, then start again: a second start must not spawn a
8131        // replacement (which would orphan this one), so the token stays cancelled.
8132        token.cancel();
8133        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8134        assert!(svc
8135            .rate_limit_poller
8136            .lock()
8137            .unwrap_or_else(PoisonError::into_inner)
8138            .as_ref()
8139            .is_some_and(|t| t.token.is_cancelled()));
8140
8141        svc.shutdown().await;
8142        assert!(svc
8143            .rate_limit_poller
8144            .lock()
8145            .unwrap_or_else(PoisonError::into_inner)
8146            .is_none());
8147    }
8148
8149    #[tokio::test]
8150    // Holds the shim guard across awaits; see the note above.
8151    #[allow(clippy::await_holding_lock)]
8152    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8153        let bin_dir = tempfile::tempdir().unwrap();
8154        let (fake, _shim) = fake_gh(
8155            bin_dir.path(),
8156            r#"{"resources":{
8157                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8158                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8159            }}"#,
8160        );
8161        let svc = WorktreesService::new();
8162        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
8163        // is being watched — a lease makes it active without needing a window/folder.
8164        svc.registry.set_polling("rust-works", "omni-dev", true);
8165        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8166
8167        // Each poll spawns a real subprocess; wait on a generous deadline so a
8168        // loaded machine fails honestly rather than flaking.
8169        let snap = tokio::time::timeout(Duration::from_secs(30), async {
8170            loop {
8171                if let Some(snap) = svc.rate_limit_cache.get() {
8172                    return snap;
8173                }
8174                tokio::time::sleep(Duration::from_millis(25)).await;
8175            }
8176        })
8177        .await
8178        .expect("poller should populate the cache through the fake gh");
8179        assert_eq!(snap.graphql.unwrap().used, 4100);
8180        assert_eq!(snap.core.unwrap().used, 27);
8181
8182        // The reading reaches the built-in status field via the shared cache.
8183        assert!(svc.rate_limit_cache().get().is_some());
8184
8185        svc.shutdown().await;
8186        assert!(svc
8187            .rate_limit_poller
8188            .lock()
8189            .unwrap_or_else(PoisonError::into_inner)
8190            .is_none());
8191    }
8192
8193    #[tokio::test]
8194    // Holds the shim guard across awaits; see the note above.
8195    #[allow(clippy::await_holding_lock)]
8196    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8197        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
8198        // `/rate_limit` subprocess — the counting stub records zero spawns.
8199        let bin_dir = tempfile::tempdir().unwrap();
8200        let (fake, _shim, counter) = counting_fake_gh(
8201            bin_dir.path(),
8202            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8203        );
8204        let svc = WorktreesService::new();
8205        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8206
8207        // Give the loop several ticks; with nothing registered it must never poll.
8208        tokio::time::sleep(Duration::from_millis(300)).await;
8209        assert_eq!(
8210            gh_spawn_count(&counter),
8211            0,
8212            "idle daemon must not poll (#1389, fix 8b)"
8213        );
8214        assert!(svc.rate_limit_cache.get().is_none());
8215
8216        // Once a lease is active, the next tick populates the cache.
8217        svc.registry.set_polling("rust-works", "omni-dev", true);
8218        tokio::time::timeout(Duration::from_secs(30), async {
8219            loop {
8220                if svc.rate_limit_cache.get().is_some() {
8221                    return;
8222                }
8223                tokio::time::sleep(Duration::from_millis(25)).await;
8224            }
8225        })
8226        .await
8227        .expect("an active lease should resume polling");
8228        assert!(gh_spawn_count(&counter) >= 1);
8229        svc.shutdown().await;
8230    }
8231
8232    #[tokio::test]
8233    async fn rate_limit_poller_survives_a_failing_gh() {
8234        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
8235        // the degraded branch keeps the last (here, absent) reading rather than
8236        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
8237        let svc = WorktreesService::new();
8238        svc.registry.set_polling("rust-works", "omni-dev", true);
8239        svc.start_rate_limit_poller_with(
8240            Duration::from_millis(20),
8241            PathBuf::from("/no/such/gh/xyzzy"),
8242        );
8243        // Let it fail a few times: the cache stays empty and the task stays alive.
8244        tokio::time::sleep(Duration::from_millis(150)).await;
8245        assert!(svc.rate_limit_cache.get().is_none());
8246        assert!(
8247            svc.rate_limit_poller
8248                .lock()
8249                .unwrap_or_else(PoisonError::into_inner)
8250                .is_some(),
8251            "the loop must survive a failing gh, not panic out"
8252        );
8253        svc.shutdown().await;
8254    }
8255
8256    #[test]
8257    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8258        let svc = WorktreesService::new();
8259        // Empty cache → no rate-limit line (the pre-#1375 shape).
8260        let items = svc.menu().items;
8261        assert!(
8262            !items
8263                .iter()
8264                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8265            "no github line before the first poll"
8266        );
8267
8268        // Populate the cache → the first item is the rate-limit status line.
8269        svc.rate_limit_cache.replace(RateLimitSnapshot {
8270            graphql: Some(rl_resource(82)),
8271            core: Some(rl_resource(3)),
8272            search: None,
8273        });
8274        let items = svc.menu().items;
8275        assert!(
8276            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8277            "expected the github line first, got {items:?}"
8278        );
8279        assert!(
8280            matches!(items.get(1), Some(MenuItem::Separator)),
8281            "expected a separator after the github line"
8282        );
8283    }
8284
8285    #[tokio::test]
8286    // Holds the shim guard across awaits; see the note above.
8287    #[allow(clippy::await_holding_lock)]
8288    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8289        let dir = tempfile::tempdir().unwrap();
8290        github_repo(dir.path());
8291        let bin_dir = tempfile::tempdir().unwrap();
8292        // One repo, one branch → aliases r0/b0. A still-running check so the badge
8293        // stays pending and the loop keeps its fast cadence.
8294        let (fake, _shim) = fake_gh(
8295            bin_dir.path(),
8296            r#"{"data":{"r0":{"b0":{
8297                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8298                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8299                ]}}},
8300                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8301            }}}}"#,
8302        );
8303        let svc = WorktreesService::new();
8304        svc.handle(
8305            "register",
8306            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8307        )
8308        .await
8309        .unwrap();
8310        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8311        // act on it, as if the user had toggled it on.
8312        svc.registry.set_polling("rust-works", "omni-dev", true);
8313        svc.start_pr_poller_with(
8314            Duration::from_millis(50),
8315            Duration::from_millis(10),
8316            fake.clone(),
8317        );
8318
8319        // Wait on a generous wall-clock deadline: each poll spawns a real
8320        // subprocess, and under a loaded machine (a full `build.sh` runs a build
8321        // and clippy alongside) a tight budget flakes rather than fails honestly.
8322        let badge = tokio::time::timeout(Duration::from_secs(30), async {
8323            loop {
8324                if let Some(PrResolution::Pr(badge)) =
8325                    svc.pr_cache.get("rust-works", "omni-dev", "main")
8326                {
8327                    return badge;
8328                }
8329                tokio::time::sleep(Duration::from_millis(25)).await;
8330            }
8331        })
8332        .await
8333        .expect("poller should resolve a badge through the fake gh");
8334        assert_eq!(badge.number, 1337);
8335        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8336
8337        // The badge reaches the wire the windows actually read.
8338        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8339        assert_eq!(wt["pr"]["number"], json!(1337));
8340
8341        // And the loop is quiescent after shutdown: the generation must stop moving.
8342        svc.shutdown().await;
8343        let generation = svc.registry.change_generation();
8344        tokio::time::sleep(Duration::from_millis(120)).await;
8345        assert_eq!(
8346            svc.registry.change_generation(),
8347            generation,
8348            "no bumps after shutdown"
8349        );
8350    }
8351
8352    #[tokio::test]
8353    // Holds the shim guard across awaits; see the note above.
8354    #[allow(clippy::await_holding_lock)]
8355    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8356        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
8357        // the poller folds into the shared cache — so the graphql figure stays fresh
8358        // without a standalone `/rate_limit` call.
8359        let dir = tempfile::tempdir().unwrap();
8360        github_repo(dir.path());
8361        let bin_dir = tempfile::tempdir().unwrap();
8362        let (fake, _shim) = fake_gh(
8363            bin_dir.path(),
8364            r#"{"data":{
8365                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8366                             "resetAt":"2026-07-21T16:00:00Z"},
8367                "r0":{"b0":{
8368                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8369                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8370                  ]}}},
8371                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8372                }}
8373            }}"#,
8374        );
8375        let svc = WorktreesService::new();
8376        svc.handle(
8377            "register",
8378            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8379        )
8380        .await
8381        .unwrap();
8382        svc.registry.set_polling("rust-works", "omni-dev", true);
8383        // No rate-limit poller started: the only writer of the cache is the PR poll's
8384        // folded-in budget, so a populated graphql reading proves fix 8a.
8385        svc.start_pr_poller_with(
8386            Duration::from_millis(50),
8387            Duration::from_millis(10),
8388            fake.clone(),
8389        );
8390
8391        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8392            loop {
8393                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8394                    return g;
8395                }
8396                tokio::time::sleep(Duration::from_millis(25)).await;
8397            }
8398        })
8399        .await
8400        .expect("the PR poll should fold its budget into the cache");
8401        assert_eq!(graphql.used, 123);
8402        assert_eq!(graphql.limit, 5000);
8403        assert_eq!(graphql.remaining, 4877);
8404        svc.shutdown().await;
8405    }
8406
8407    #[tokio::test]
8408    // Holds the shim guard across awaits; see the note above.
8409    #[allow(clippy::await_holding_lock)]
8410    async fn pr_poll_counts_every_gh_call_exactly_once() {
8411        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
8412        // still counted. Compares the ground-truth number of `gh` subprocesses the
8413        // poll actually spawned against the number of successful `kind:"gh"` records
8414        // the counted `run_gh` choke point wrote — they must be equal, so a future
8415        // refactor cannot add an uncounted `gh` path (counted < spawns) without
8416        // failing here.
8417        let dir = tempfile::tempdir().unwrap();
8418        github_repo(dir.path());
8419        let bin_dir = tempfile::tempdir().unwrap();
8420        let (fake, _shim, counter) = counting_fake_gh(
8421            bin_dir.path(),
8422            r#"{"data":{"r0":{"b0":{
8423                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8424                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8425                ]}}},
8426                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8427            }}}}"#,
8428        );
8429        let log = bin_dir.path().join("log.jsonl");
8430        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8431
8432        let svc = WorktreesService::new();
8433        svc.handle(
8434            "register",
8435            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8436        )
8437        .await
8438        .unwrap();
8439        svc.registry.set_polling("rust-works", "omni-dev", true);
8440        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8441
8442        // Wait for at least one fetch to land.
8443        tokio::time::timeout(Duration::from_secs(30), async {
8444            loop {
8445                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8446                    return;
8447                }
8448                tokio::time::sleep(Duration::from_millis(25)).await;
8449            }
8450        })
8451        .await
8452        .expect("poller should fetch through the fake gh");
8453
8454        // Stop the loop so both counts are final (no in-flight gh), then compare.
8455        svc.shutdown().await;
8456        let spawns = gh_spawn_count(&counter);
8457        let counted = counted_gh_records(&log);
8458        std::env::remove_var("OMNI_DEV_LOG_FILE");
8459        assert!(
8460            spawns >= 1,
8461            "the poll should have spent at least one gh call"
8462        );
8463        assert_eq!(
8464            counted, spawns,
8465            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8466        );
8467    }
8468
8469    #[tokio::test]
8470    // Holds the shim guard across awaits; see the note above.
8471    #[allow(clippy::await_holding_lock)]
8472    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8473        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
8474        // its windows) that each *grow* the watch must collapse into ONE fetch on
8475        // the final set, not one per window. Two distinct repos appear back-to-back
8476        // inside the debounce window; a debounce-free loop would fetch twice.
8477        let dir_a = tempfile::tempdir().unwrap();
8478        let dir_b = tempfile::tempdir().unwrap();
8479        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
8480        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
8481        let bin_dir = tempfile::tempdir().unwrap();
8482        // Both terminal, so no fast pending cadence can add a second fetch.
8483        let (fake, _shim, counter) = counting_fake_gh(
8484            bin_dir.path(),
8485            r#"{"data":{
8486                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8487                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8488                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8489                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8490                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8491                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8492            }}"#,
8493        );
8494        let svc = WorktreesService::new();
8495        // Enable polling for both before they register, so the first snapshot after
8496        // the storm already counts them.
8497        svc.registry.set_polling("rust-works", "omni-dev", true);
8498        svc.registry.set_polling("rust-works", "other-repo", true);
8499        // `base` far larger than the test so only the storm — never the cadence —
8500        // can trigger a fetch; a generous debounce so the two registers land inside
8501        // one settle window even on a loaded machine.
8502        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8503        // The burst: both windows register back-to-back.
8504        svc.handle(
8505            "register",
8506            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8507        )
8508        .await
8509        .unwrap();
8510        // A beat between the two, so the first bump has (all but certainly)
8511        // woken the poller into its settle window before the second arrives —
8512        // exercising the debounce *restart*, not just a single coalesced wake.
8513        tokio::time::sleep(Duration::from_millis(50)).await;
8514        svc.handle(
8515            "register",
8516            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8517        )
8518        .await
8519        .unwrap();
8520
8521        // Wait until both badges resolve — proving the single fetch covered the full
8522        // final set, not just the first window.
8523        tokio::time::timeout(Duration::from_secs(30), async {
8524            loop {
8525                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8526                let b = svc
8527                    .pr_cache
8528                    .get("rust-works", "other-repo", "main")
8529                    .is_some();
8530                if a && b {
8531                    return;
8532                }
8533                tokio::time::sleep(Duration::from_millis(25)).await;
8534            }
8535        })
8536        .await
8537        .expect("the debounced fetch should resolve both repos");
8538
8539        svc.shutdown().await;
8540        assert_eq!(
8541            gh_spawn_count(&counter),
8542            1,
8543            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8544        );
8545    }
8546
8547    #[tokio::test]
8548    // Holds the shim guard across awaits; see the note above.
8549    #[allow(clippy::await_holding_lock)]
8550    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8551        // The settle loop is bounded: a drip of registry bumps, each landing
8552        // inside the debounce window, must not postpone the poll forever — the
8553        // overall deadline (4× the debounce) forces the snapshot mid-storm
8554        // (#1389, fix 2).
8555        let dir = tempfile::tempdir().unwrap();
8556        github_repo(dir.path());
8557        let bin_dir = tempfile::tempdir().unwrap();
8558        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8559        let svc = WorktreesService::new();
8560        svc.registry.set_polling("rust-works", "omni-dev", true);
8561        // `base` far past the timeout below, so only the grew-trigger — released by
8562        // the deadline — can fetch; a base the drip could outlive would let the
8563        // periodic cadence satisfy the assertion on a very slow run (#1426).
8564        svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8565        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8566        svc.handle("register", register.clone()).await.unwrap();
8567        // Re-register (an upsert, but still a bump) every 25ms — inside the 50ms
8568        // debounce — until the deadline-forced fetch actually lands. The observation
8569        // point is the condition itself, never a clock: sampling the counter at a
8570        // fixed instant would additionally assert that a `fork` + `exec` + `/bin/sh`
8571        // startup + file append all beat that instant, which is what made this flaky
8572        // under full-suite load (#1426). Under load the drip just runs longer. Every
8573        // iteration bumps, so the fetch is still observed *while bumps are arriving*
8574        // — a deadline-free settle loop never fetches here at all, and the timeout
8575        // *is* the assertion.
8576        let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8577            loop {
8578                tokio::time::sleep(Duration::from_millis(25)).await;
8579                svc.handle("register", register.clone()).await.unwrap();
8580                if gh_spawn_count(&counter) >= 1 {
8581                    return;
8582                }
8583            }
8584        })
8585        .await;
8586        svc.shutdown().await;
8587        forced_mid_drip.expect(
8588            "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8589        );
8590    }
8591
8592    #[tokio::test]
8593    // Holds the shim guard across awaits; see the note above.
8594    #[allow(clippy::await_holding_lock)]
8595    async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8596        // #1389, fix 4: a daemon restart within the backoff window serves badges
8597        // from the persisted cache and spends **no** gh call, because every current
8598        // target already has a fresh verdict.
8599        let dir = tempfile::tempdir().unwrap();
8600        let repo = github_repo(dir.path());
8601        let head = repo.head().unwrap().target().unwrap().to_string();
8602        let bin_dir = tempfile::tempdir().unwrap();
8603        // If the poller wrongly fetched, this empty reply would still spawn the stub
8604        // and bump the counter — which is exactly what the assertion catches.
8605        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8606
8607        // Persist a fresh cache the way the previous daemon would have: a badge for
8608        // `main` whose verdict is about the current head (so it is not stale),
8609        // watched at `(main, no upstream)`, resolved just now.
8610        let cache_path = bin_dir.path().join("pr-cache.json");
8611        let target = PrTarget {
8612            owner: "rust-works".into(),
8613            name: "omni-dev".into(),
8614            branch: "main".into(),
8615        };
8616        let prefs = pr_cache_prefs_from(
8617            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8618            &[watch("main", None)],
8619            Utc::now(),
8620        );
8621        write_pr_cache(&cache_path, &prefs).unwrap();
8622
8623        let svc = WorktreesService::new();
8624        svc.load_pr_cache(cache_path);
8625        svc.registry.set_polling("rust-works", "omni-dev", true);
8626        svc.handle(
8627            "register",
8628            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8629        )
8630        .await
8631        .unwrap();
8632        // `base` far larger than the test: the only fetch that could happen is the
8633        // immediate one we expect the warm cache to skip.
8634        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8635
8636        // The restored badge renders on the wire without a gh call.
8637        let number = tokio::time::timeout(Duration::from_secs(30), async {
8638            loop {
8639                let tree = svc.handle("tree", Value::Null).await.unwrap();
8640                if let Some(n) = repos_of(&tree)
8641                    .first()
8642                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8643                {
8644                    return n;
8645                }
8646                tokio::time::sleep(Duration::from_millis(25)).await;
8647            }
8648        })
8649        .await
8650        .expect("the restored badge should render from the warm cache");
8651        assert_eq!(number, 1337);
8652
8653        // Let the poller run a while, then confirm it stayed quiet.
8654        tokio::time::sleep(Duration::from_millis(300)).await;
8655        svc.shutdown().await;
8656        assert_eq!(
8657            gh_spawn_count(&counter),
8658            0,
8659            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8660        );
8661    }
8662
8663    #[tokio::test]
8664    // Holds the shim guard across awaits; see the note above.
8665    #[allow(clippy::await_holding_lock)]
8666    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8667        // #1389, fix 4, write side (the twin of the skip test above, which reads
8668        // a hand-written file): a successful resolve persists the cache —
8669        // creating the runtime dir if needed — so the *next* daemon restart
8670        // warm-starts from it.
8671        let dir = tempfile::tempdir().unwrap();
8672        github_repo(dir.path());
8673        let bin_dir = tempfile::tempdir().unwrap();
8674        let (fake, _shim) = fake_gh(
8675            bin_dir.path(),
8676            r#"{"data":{"r0":{"b0":{
8677                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8678                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8679                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8680            }}}}"#,
8681        );
8682        let svc = WorktreesService::new();
8683        // No file yet, and no parent dir either: the load takes the benign
8684        // NotFound arm, and the write must create the `0700` runtime dir.
8685        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8686        svc.load_pr_cache(cache_path.clone());
8687        svc.registry.set_polling("rust-works", "omni-dev", true);
8688        svc.handle(
8689            "register",
8690            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8691        )
8692        .await
8693        .unwrap();
8694        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8695
8696        // A partially-written or not-yet-written file simply retries: only a
8697        // fully parseable cache ends the wait.
8698        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8699            loop {
8700                if let Ok(bytes) = std::fs::read(&cache_path) {
8701                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8702                        if !prefs.entries.is_empty() {
8703                            return prefs;
8704                        }
8705                    }
8706                }
8707                tokio::time::sleep(Duration::from_millis(25)).await;
8708            }
8709        })
8710        .await
8711        .expect("a successful resolve should persist the cache file");
8712        svc.shutdown().await;
8713
8714        assert_eq!(prefs.entries[0].target.branch, "main");
8715        assert!(
8716            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8717            "{:?}",
8718            prefs.entries[0].resolution
8719        );
8720        assert_eq!(
8721            prefs.watched,
8722            vec![PersistedWatch {
8723                target: prefs.entries[0].target.clone(),
8724                upstream_sha: None
8725            }]
8726        );
8727        assert!(
8728            prefs.polled_at.is_some(),
8729            "the poll time is what ages the next warm start"
8730        );
8731    }
8732
8733    #[tokio::test]
8734    // Holds the shim guard across awaits; see the note above.
8735    #[allow(clippy::await_holding_lock)]
8736    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8737        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
8738        // to one counted `gh pr list` per repo. A generous TTL, so the second call
8739        // is served from the cache and spawns **no** second `gh` — the whole point.
8740        let bin_dir = tempfile::tempdir().unwrap();
8741        let (fake, _shim, counter) = counting_fake_gh(
8742            bin_dir.path(),
8743            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8744                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8745        );
8746        let svc = WorktreesService::new();
8747
8748        let prs = svc
8749            .open_prs_with("rust-works", "omni-dev", fake.clone())
8750            .await
8751            .expect("gh pr list should resolve");
8752        assert_eq!(prs.len(), 1);
8753        assert_eq!(prs[0]["number"], json!(42));
8754        assert_eq!(prs[0]["url"], json!("http://x/42"));
8755        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8756
8757        // A second window asking the same repo is served from the shared cache.
8758        let again = svc
8759            .open_prs_with("rust-works", "omni-dev", fake.clone())
8760            .await
8761            .expect("cache hit should resolve");
8762        assert_eq!(again, prs);
8763        assert_eq!(
8764            gh_spawn_count(&counter),
8765            1,
8766            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8767        );
8768
8769        // The op wrapper shapes the reply and validates the payload.
8770        let reply = svc
8771            .handle(
8772                "open-prs",
8773                json!({ "owner": "rust-works", "name": "omni-dev" }),
8774            )
8775            .await
8776            .expect("open-prs op should route");
8777        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8778        assert!(svc
8779            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
8780            .await
8781            .is_err());
8782    }
8783
8784    #[test]
8785    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8786        // The three degraded shapes a real `gh` presents — not installed, a
8787        // nonzero exit (auth/network), and output that is not the JSON array
8788        // the menu indexes into — must each be a distinct, actionable error
8789        // rather than a panic or a silently empty list (#1389, fix 7).
8790        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8791        assert!(
8792            err.to_string().contains("is the GitHub CLI installed"),
8793            "{err:#}"
8794        );
8795
8796        let bin_dir = tempfile::tempdir().unwrap();
8797        let _guard = shim_lock();
8798        let failing = bin_dir.path().join("fake-gh-fails");
8799        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8800        let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8801        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8802        assert!(err.to_string().contains("boom"), "{err:#}");
8803
8804        let object = bin_dir.path().join("fake-gh-object");
8805        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8806        let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8807        assert!(
8808            err.to_string().contains("did not return a JSON array"),
8809            "{err:#}"
8810        );
8811    }
8812
8813    #[tokio::test]
8814    // Holds the shim guard across awaits; see the note above.
8815    #[allow(clippy::await_holding_lock)]
8816    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8817        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
8818        // cadence and ignores even a grown watch, so no runaway can drain the shared
8819        // budget. A recent warm `last_poll` is seeded so the "first sight always
8820        // fetches" base case cannot mask the throttle — the only thing that could
8821        // fetch here is the grew-trigger, which the throttle suppresses.
8822        let dir = tempfile::tempdir().unwrap();
8823        github_repo(dir.path());
8824        let bin_dir = tempfile::tempdir().unwrap();
8825        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8826
8827        let svc = WorktreesService::new();
8828        // Warm start with an *empty* watch but a fresh poll time: the registered
8829        // repo then reads as a grown watch, while `last_poll` is recent enough that
8830        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
8831        *svc.pr_warm_start
8832            .lock()
8833            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8834            watched: vec![],
8835            polled_at: Utc::now(),
8836        });
8837        // Budget over the warn threshold before the poller starts.
8838        svc.rate_limit_cache.replace(RateLimitSnapshot {
8839            graphql: Some(rl_resource(90)),
8840            core: Some(rl_resource(3)),
8841            search: None,
8842        });
8843        svc.registry.set_polling("rust-works", "omni-dev", true);
8844        svc.handle(
8845            "register",
8846            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8847        )
8848        .await
8849        .unwrap();
8850        // `base` far larger than the test, so a fetch could only come from the
8851        // grew-trigger the throttle is meant to suppress.
8852        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8853
8854        // Let the poller wake on the registration and run a while.
8855        tokio::time::sleep(Duration::from_millis(300)).await;
8856        svc.shutdown().await;
8857        assert_eq!(
8858            gh_spawn_count(&counter),
8859            0,
8860            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8861        );
8862    }
8863
8864    #[tokio::test]
8865    // Holds the shim guard across awaits; see the note above.
8866    #[allow(clippy::await_holding_lock)]
8867    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8868        // The diff-and-drop contract: an unchanged poll must not bump, or every
8869        // window re-renders on every tick — the cost this design exists to avoid.
8870        let dir = tempfile::tempdir().unwrap();
8871        github_repo(dir.path());
8872        let bin_dir = tempfile::tempdir().unwrap();
8873        let (fake, _shim) = fake_gh(
8874            bin_dir.path(),
8875            r#"{"data":{"r0":{"b0":{
8876                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8877                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8878                ]}}},
8879                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8880            }}}}"#,
8881        );
8882        let svc = WorktreesService::new();
8883        svc.handle(
8884            "register",
8885            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8886        )
8887        .await
8888        .unwrap();
8889        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8890        // act on it, as if the user had toggled it on.
8891        svc.registry.set_polling("rust-works", "omni-dev", true);
8892        svc.start_pr_poller_with(
8893            Duration::from_millis(50),
8894            Duration::from_millis(10),
8895            fake.clone(),
8896        );
8897
8898        tokio::time::timeout(Duration::from_secs(30), async {
8899            loop {
8900                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8901                    return;
8902                }
8903                tokio::time::sleep(Duration::from_millis(25)).await;
8904            }
8905        })
8906        .await
8907        .expect("poller should resolve a badge through the fake gh");
8908        // The fake always answers identically, so after the first resolve every
8909        // subsequent poll is a no-change and must leave the generation alone.
8910        let settled = svc.registry.change_generation();
8911        tokio::time::sleep(Duration::from_millis(150)).await;
8912        assert_eq!(
8913            svc.registry.change_generation(),
8914            settled,
8915            "an unchanged poll must not bump the change-notify"
8916        );
8917        svc.shutdown().await;
8918    }
8919
8920    #[tokio::test]
8921    // Holds the shim guard across awaits; see the note above.
8922    #[allow(clippy::await_holding_lock)]
8923    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8924        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
8925        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
8926        // as `pr_none`, and — since the answer never changes — bumps the
8927        // change-notify only for the poll that first delivered it.
8928        let dir = tempfile::tempdir().unwrap();
8929        github_repo(dir.path());
8930        let bin_dir = tempfile::tempdir().unwrap();
8931        let (fake, _shim) = fake_gh(
8932            bin_dir.path(),
8933            r#"{"data":{"r0":{"b0":{
8934                "target":{"oid":"abc","statusCheckRollup":null},
8935                "associatedPullRequests":{"nodes":[]}
8936            }}}}"#,
8937        );
8938        let svc = WorktreesService::new();
8939        svc.handle(
8940            "register",
8941            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8942        )
8943        .await
8944        .unwrap();
8945        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8946        // act on it, as if the user had toggled it on.
8947        svc.registry.set_polling("rust-works", "omni-dev", true);
8948        svc.start_pr_poller_with(
8949            Duration::from_millis(50),
8950            Duration::from_millis(10),
8951            fake.clone(),
8952        );
8953
8954        tokio::time::timeout(Duration::from_secs(30), async {
8955            loop {
8956                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8957                    return;
8958                }
8959                tokio::time::sleep(Duration::from_millis(25)).await;
8960            }
8961        })
8962        .await
8963        .expect("poller should resolve the negative through the fake gh");
8964
8965        // The negative reaches the wire the windows actually read.
8966        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8967        assert_eq!(wt["pr_none"], json!(true));
8968        assert!(wt.get("pr").is_none(), "{wt:?}");
8969
8970        // Identical re-polls of the same negative must not bump.
8971        let settled = svc.registry.change_generation();
8972        tokio::time::sleep(Duration::from_millis(150)).await;
8973        assert_eq!(
8974            svc.registry.change_generation(),
8975            settled,
8976            "an unchanged negative must not bump the change-notify"
8977        );
8978        svc.shutdown().await;
8979    }
8980
8981    #[test]
8982    fn sync_indicator_formats_only_with_upstream() {
8983        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8984        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8985        assert_eq!(sync_indicator(None, None), None);
8986        // A partial pair (no real upstream) yields nothing.
8987        assert_eq!(sync_indicator(Some(1), None), None);
8988    }
8989
8990    #[tokio::test]
8991    async fn list_enriches_entries_with_git_status() {
8992        let dir = tempfile::tempdir().unwrap();
8993        let repo = init_repo(dir.path());
8994        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8995        repo.set_head("refs/heads/main").unwrap();
8996
8997        let svc = WorktreesService::new();
8998        svc.handle(
8999            "register",
9000            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9001        )
9002        .await
9003        .unwrap();
9004        let payload = svc.handle("list", Value::Null).await.unwrap();
9005        let windows = windows_of(&payload);
9006        assert_eq!(windows.len(), 1);
9007        assert_eq!(
9008            windows[0].get("branch").and_then(Value::as_str),
9009            Some("main")
9010        );
9011        // No upstream configured → the ahead/behind keys are absent, not zero.
9012        assert!(windows[0].get("ahead").is_none());
9013        assert!(windows[0].get("behind").is_none());
9014        // The main repo name is enriched onto the entry.
9015        assert_eq!(
9016            windows[0].get("main_repo").and_then(Value::as_str),
9017            dir.path().file_name().and_then(|n| n.to_str())
9018        );
9019
9020        // A non-repo folder is still listed, just without a branch or main repo.
9021        let plain = tempfile::tempdir().unwrap();
9022        svc.handle(
9023            "register",
9024            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9025        )
9026        .await
9027        .unwrap();
9028        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9029        let w2 = windows
9030            .iter()
9031            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9032            .unwrap();
9033        assert!(w2.get("branch").is_none());
9034        assert!(w2.get("main_repo").is_none());
9035    }
9036
9037    #[test]
9038    fn window_label_prefers_git_branch_over_title() {
9039        let dir = tempfile::tempdir().unwrap();
9040        let repo = init_repo(dir.path());
9041        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9042        repo.set_head("refs/heads/main").unwrap();
9043        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9044        let entry = WindowEntry {
9045            key: "k".to_string(),
9046            folders: vec![dir.path().to_path_buf()],
9047            // Both the companion `repo` and `title` are overridden by the
9048            // git-derived main repo name and computed branch.
9049            repo: Some("companion-repo".to_string()),
9050            title: Some("ignored title".to_string()),
9051            pid: None,
9052            last_seen: Utc::now(),
9053        };
9054        // Main checkout: `repo · branch`, and with no upstream there is no sync.
9055        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9056    }
9057
9058    #[tokio::test]
9059    async fn list_includes_ahead_behind_for_tracking_branch() {
9060        let dir = tempfile::tempdir().unwrap();
9061        let _repo = diverging_repo(dir.path());
9062
9063        let svc = WorktreesService::new();
9064        svc.handle(
9065            "register",
9066            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9067        )
9068        .await
9069        .unwrap();
9070        let payload = svc.handle("list", Value::Null).await.unwrap();
9071        let windows = windows_of(&payload);
9072        // A tracking branch serializes branch plus both divergence counts.
9073        assert_eq!(
9074            windows[0].get("branch").and_then(Value::as_str),
9075            Some("main")
9076        );
9077        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9078        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9079    }
9080
9081    #[test]
9082    fn window_label_includes_sync_for_tracking_branch() {
9083        let dir = tempfile::tempdir().unwrap();
9084        let _repo = diverging_repo(dir.path());
9085        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9086        let entry = WindowEntry {
9087            key: "k".to_string(),
9088            folders: vec![dir.path().to_path_buf()],
9089            repo: Some("companion-repo".to_string()),
9090            title: None,
9091            pid: None,
9092            last_seen: Utc::now(),
9093        };
9094        // A tracking branch appends the `(+ahead -behind)` sync indicator.
9095        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9096    }
9097
9098    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
9099    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
9100    /// <wt_path>`.
9101    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9102        let commit = repo.find_commit(base).unwrap();
9103        repo.branch(branch, &commit, false).unwrap();
9104        let reference = repo
9105            .find_reference(&format!("refs/heads/{branch}"))
9106            .unwrap();
9107        let mut opts = git2::WorktreeAddOptions::new();
9108        opts.reference(Some(&reference));
9109        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9110    }
9111
9112    #[test]
9113    fn git_status_marks_linked_worktree_and_names_parent_repo() {
9114        let main_dir = tempfile::tempdir().unwrap();
9115        let repo = init_repo(main_dir.path());
9116        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9117        repo.set_head("refs/heads/main").unwrap();
9118
9119        // A linked worktree checked out on a new `feature` branch, in a
9120        // directory whose basename is deliberately *not* the repo name.
9121        let wt_parent = tempfile::tempdir().unwrap();
9122        let wt_path = wt_parent.path().join("feature-wt");
9123        add_worktree(&repo, a, &wt_path, "feature");
9124
9125        let status = git_status(&wt_path);
9126        assert!(status.is_worktree);
9127        assert_eq!(status.branch.as_deref(), Some("feature"));
9128        // The worktree names its *parent* repo, not its worktree-folder basename.
9129        assert_eq!(
9130            status.main_repo.as_deref(),
9131            main_dir.path().file_name().and_then(|n| n.to_str())
9132        );
9133
9134        // The main checkout resolves the same repo name and is not a worktree.
9135        let main_status = git_status(main_dir.path());
9136        assert!(!main_status.is_worktree);
9137        assert_eq!(main_status.main_repo, status.main_repo);
9138    }
9139
9140    #[test]
9141    fn window_label_marks_worktree_with_fork_glyph() {
9142        let main_dir = tempfile::tempdir().unwrap();
9143        let repo = init_repo(main_dir.path());
9144        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9145        repo.set_head("refs/heads/main").unwrap();
9146        let wt_parent = tempfile::tempdir().unwrap();
9147        let wt_path = wt_parent.path().join("feature-wt");
9148        add_worktree(&repo, a, &wt_path, "feature");
9149
9150        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9151        let entry = WindowEntry {
9152            key: "k".to_string(),
9153            folders: vec![wt_path],
9154            repo: Some("feature-wt".to_string()),
9155            title: None,
9156            pid: None,
9157            last_seen: Utc::now(),
9158        };
9159        // A worktree line: parent repo, the fork glyph, then the branch (no
9160        // upstream here, so no sync suffix).
9161        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9162    }
9163
9164    #[test]
9165    fn main_repo_name_derives_from_common_dir() {
9166        // Normal layout: the repo is the directory that contains `.git`.
9167        assert_eq!(
9168            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9169            Some("omni-dev")
9170        );
9171        // A trailing slash on the common dir does not change the answer.
9172        assert_eq!(
9173            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9174            Some("omni-dev")
9175        );
9176        // A bare repo: its own directory name, without the `.git` suffix.
9177        assert_eq!(
9178            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9179            Some("omni-dev")
9180        );
9181        // A `.git` at the filesystem root has no parent name to use.
9182        assert_eq!(main_repo_name(Path::new("/.git")), None);
9183    }
9184
9185    // --- Repo/worktree tree (#1265) ----------------------------------------
9186
9187    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
9188    /// temporary payload).
9189    fn repos_of(payload: &Value) -> Vec<Value> {
9190        payload
9191            .get("repos")
9192            .and_then(Value::as_array)
9193            .expect("repos array")
9194            .clone()
9195    }
9196
9197    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9198        Some(GithubIdentity {
9199            owner: owner.to_string(),
9200            name: name.to_string(),
9201        })
9202    }
9203
9204    #[test]
9205    fn github_identity_parses_supported_forms() {
9206        // https / http, with and without the `.git` suffix.
9207        assert_eq!(
9208            github_identity("https://github.com/rust-works/omni-dev.git"),
9209            github("rust-works", "omni-dev")
9210        );
9211        assert_eq!(
9212            github_identity("https://github.com/rust-works/omni-dev"),
9213            github("rust-works", "omni-dev")
9214        );
9215        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9216        // SCP-like and ssh:// / git:// forms.
9217        assert_eq!(
9218            github_identity("git@github.com:rust-works/omni-dev.git"),
9219            github("rust-works", "omni-dev")
9220        );
9221        assert_eq!(
9222            github_identity("ssh://git@github.com/o/r.git"),
9223            github("o", "r")
9224        );
9225        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9226        // A trailing slash and surrounding whitespace are tolerated.
9227        assert_eq!(
9228            github_identity("  https://github.com/o/r/  "),
9229            github("o", "r")
9230        );
9231    }
9232
9233    #[test]
9234    fn github_identity_rejects_non_github_and_malformed() {
9235        // Non-GitHub hosts.
9236        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9237        assert_eq!(github_identity("git@example.com:o/r.git"), None);
9238        // Missing or extra path segments.
9239        assert_eq!(github_identity("https://github.com/onlyowner"), None);
9240        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9241        assert_eq!(github_identity("https://github.com/"), None);
9242        // Not a URL at all.
9243        assert_eq!(github_identity("not a url"), None);
9244    }
9245
9246    #[test]
9247    fn remote_github_identity_reads_origin_then_falls_back() {
9248        let dir = tempfile::tempdir().unwrap();
9249        let repo = init_repo(dir.path());
9250        // No remotes → None.
9251        assert_eq!(remote_github_identity(&repo), None);
9252        // A non-GitHub origin is not a match.
9253        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9254        assert_eq!(remote_github_identity(&repo), None);
9255        // A GitHub origin resolves to its identity.
9256        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9257            .unwrap();
9258        assert_eq!(
9259            remote_github_identity(&repo),
9260            github("rust-works", "omni-dev")
9261        );
9262
9263        // Origin non-GitHub but another remote is GitHub: the fallback loop over
9264        // the remaining remotes finds it.
9265        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9266            .unwrap();
9267        repo.remote("upstream", "https://github.com/other/proj.git")
9268            .unwrap();
9269        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9270    }
9271
9272    #[tokio::test]
9273    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9274        let svc = WorktreesService::new();
9275        // No windows → an empty repo set (not an error), toggle at its default.
9276        assert_eq!(
9277            svc.handle("tree", Value::Null).await.unwrap(),
9278            json!({ "repos": [], "show_closed": true })
9279        );
9280        // A plain non-repo folder is skipped rather than sinking the op.
9281        let plain = tempfile::tempdir().unwrap();
9282        svc.handle(
9283            "register",
9284            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9285        )
9286        .await
9287        .unwrap();
9288        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9289    }
9290
9291    #[tokio::test]
9292    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9293        let main_dir = tempfile::tempdir().unwrap();
9294        let repo = init_repo(main_dir.path());
9295        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9296        repo.set_head("refs/heads/main").unwrap();
9297        // A GitHub origin so the repo carries an identity in the payload.
9298        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9299            .unwrap();
9300
9301        // A linked worktree on a new `feature` branch, in a directory whose
9302        // basename is deliberately not the repo name.
9303        let wt_parent = tempfile::tempdir().unwrap();
9304        let wt_path = wt_parent.path().join("feature-wt");
9305        add_worktree(&repo, a, &wt_path, "feature");
9306
9307        let svc = WorktreesService::new();
9308        // A window open on the main checkout and one on the linked worktree —
9309        // two windows, but one repo (they must dedupe).
9310        svc.handle(
9311            "register",
9312            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9313        )
9314        .await
9315        .unwrap();
9316        svc.handle(
9317            "register",
9318            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9319        )
9320        .await
9321        .unwrap();
9322
9323        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9324        assert_eq!(
9325            repos.len(),
9326            1,
9327            "two worktrees of one repo dedupe: {repos:?}"
9328        );
9329        let repo0 = &repos[0];
9330        // Repo identity is the parent-repo name (not a worktree-folder basename).
9331        assert_eq!(
9332            repo0.get("main_repo").and_then(Value::as_str),
9333            main_dir.path().file_name().and_then(|n| n.to_str())
9334        );
9335        assert_eq!(
9336            repo0.pointer("/github/owner").and_then(Value::as_str),
9337            Some("rust-works")
9338        );
9339        assert_eq!(
9340            repo0.pointer("/github/name").and_then(Value::as_str),
9341            Some("omni-dev")
9342        );
9343        assert!(repo0.get("root").and_then(Value::as_str).is_some());
9344
9345        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9346        assert_eq!(worktrees.len(), 2);
9347        // Main working tree first: is_main, open, with the main window's key.
9348        let main_wt = &worktrees[0];
9349        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9350        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9351        assert_eq!(
9352            main_wt.get("window_key").and_then(Value::as_str),
9353            Some("wm")
9354        );
9355        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9356        // Linked worktree: not main, open via the feature window.
9357        let linked = &worktrees[1];
9358        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9359        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9360        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9361        assert_eq!(
9362            linked.get("branch").and_then(Value::as_str),
9363            Some("feature")
9364        );
9365    }
9366
9367    #[tokio::test]
9368    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9369        let main_dir = tempfile::tempdir().unwrap();
9370        let repo = init_repo(main_dir.path());
9371        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9372        repo.set_head("refs/heads/main").unwrap();
9373        // No remote at all → the repo carries no `github` identity.
9374        let wt_parent = tempfile::tempdir().unwrap();
9375        let wt_path = wt_parent.path().join("feature-wt");
9376        add_worktree(&repo, a, &wt_path, "feature");
9377
9378        let svc = WorktreesService::new();
9379        // Only the main checkout has a window; the linked worktree has none.
9380        svc.handle(
9381            "register",
9382            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9383        )
9384        .await
9385        .unwrap();
9386
9387        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9388        assert_eq!(repos.len(), 1);
9389        assert!(repos[0].get("github").is_none(), "no remote → no github");
9390        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9391        let linked = worktrees
9392            .iter()
9393            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9394            .expect("the linked worktree");
9395        // Enumerated even though no window has it open, and marked closed.
9396        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9397        assert!(linked.get("window_key").is_none());
9398    }
9399
9400    // --- Close op (#1277) --------------------------------------------------
9401
9402    /// Builds a repo whose main working tree is on `trunk` with one **clean**
9403    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
9404    /// paths stay valid) and the linked worktree path.
9405    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9406        let main_dir = tempfile::tempdir().unwrap();
9407        let repo = init_repo(main_dir.path());
9408        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9409        repo.set_head("refs/heads/trunk").unwrap();
9410        let wt_parent = tempfile::tempdir().unwrap();
9411        let wt_path = wt_parent.path().join("feature-wt");
9412        add_worktree(&repo, a, &wt_path, "feature");
9413        (main_dir, wt_parent, wt_path)
9414    }
9415
9416    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
9417    /// repo — the shape a multi-select delete fans out over, and the only one where
9418    /// two prunes share a `.git/worktrees` to race on (#1359).
9419    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9420    {
9421        let main_dir = tempfile::tempdir().unwrap();
9422        let repo = init_repo(main_dir.path());
9423        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9424        repo.set_head("refs/heads/trunk").unwrap();
9425        let wt_parent = tempfile::tempdir().unwrap();
9426        let first = wt_parent.path().join("first-wt");
9427        let second = wt_parent.path().join("second-wt");
9428        add_worktree(&repo, a, &first, "first");
9429        add_worktree(&repo, a, &second, "second");
9430        (main_dir, wt_parent, first, second)
9431    }
9432
9433    #[tokio::test]
9434    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9435        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9436        let svc = Arc::new(WorktreesService::new());
9437
9438        // The multi-select fan-out: one `close` op per target, both in flight at
9439        // once against the one repo's shared admin state. Genuinely concurrent
9440        // even on this single-threaded runtime — each op's prune is a
9441        // `spawn_blocking`, so awaiting its join yields to the other op.
9442        //
9443        // This guards the fan-out end-to-end (both ops complete, neither is
9444        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
9445        // a race detector for the lock, because it is not one — it passes with the
9446        // guard removed, the two prunes being far too quick to collide reliably.
9447        let close = |path: PathBuf| {
9448            let svc = svc.clone();
9449            async move {
9450                svc.handle(
9451                    "close",
9452                    json!({ "path": path, "remove": true, "confirmed": true }),
9453                )
9454                .await
9455            }
9456        };
9457        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9458
9459        assert_eq!(a.unwrap(), json!({ "removed": true }));
9460        assert_eq!(b.unwrap(), json!({ "removed": true }));
9461        assert!(!first.exists());
9462        assert!(!second.exists());
9463        // Both *admin* entries pruned too, not merely the directories — the half
9464        // the two ops contend on.
9465        let repo = Repository::open(main_dir.path()).unwrap();
9466        assert!(repo.worktrees().unwrap().is_empty());
9467    }
9468
9469    // --- Merge-queue op (#1401) --------------------------------------------
9470
9471    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
9472    /// upstream points at the **same** commit (nothing to push) and a github
9473    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
9474    /// empty (clean) working directory.
9475    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9476        let repo = init_repo(dir);
9477        let refname = format!("refs/heads/{branch}");
9478        let head = empty_commit(&repo, Some(&refname), &[], "A");
9479        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9480            .unwrap();
9481        repo.set_head(&refname).unwrap();
9482        let mut cfg = repo.config().unwrap();
9483        cfg.set_str("remote.origin.url", url).unwrap();
9484        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9485            .unwrap();
9486        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9487            .unwrap();
9488        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9489            .unwrap();
9490        repo
9491    }
9492
9493    #[test]
9494    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9495        let dir = tempfile::tempdir().unwrap();
9496        let _repo = pushed_github_repo(
9497            dir.path(),
9498            "https://github.com/rust-works/omni-dev.git",
9499            "feature",
9500        );
9501        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9502        assert_eq!(
9503            ok.target,
9504            PrTarget {
9505                owner: "rust-works".into(),
9506                name: "omni-dev".into(),
9507                branch: "feature".into(),
9508            }
9509        );
9510        assert!(!ok.head_sha.is_empty());
9511    }
9512
9513    #[test]
9514    fn evaluate_local_skips_an_unborn_head() {
9515        let dir = tempfile::tempdir().unwrap();
9516        let _repo = init_repo(dir.path()); // no commits
9517        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9518    }
9519
9520    #[test]
9521    fn evaluate_local_skips_a_branch_with_no_upstream() {
9522        let dir = tempfile::tempdir().unwrap();
9523        let repo = init_repo(dir.path());
9524        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9525        repo.set_head("refs/heads/main").unwrap();
9526        // A github URL but no tracking config → nothing was ever pushed.
9527        repo.config()
9528            .unwrap()
9529            .set_str("remote.origin.url", "https://github.com/o/r.git")
9530            .unwrap();
9531        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9532    }
9533
9534    #[test]
9535    fn evaluate_local_skips_unpushed_local_commits() {
9536        let dir = tempfile::tempdir().unwrap();
9537        let repo = init_repo(dir.path());
9538        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9539        let a_commit = repo.find_commit(a).unwrap();
9540        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
9541        repo.reference("refs/remotes/origin/main", a, true, "o")
9542            .unwrap();
9543        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9544        drop(a_commit);
9545        repo.set_head("refs/heads/main").unwrap();
9546        let mut cfg = repo.config().unwrap();
9547        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9548            .unwrap();
9549        // The fetch refspec is what lets git2 map the branch to its tracking ref;
9550        // without it `upstream()` fails and the branch reads as having none.
9551        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9552            .unwrap();
9553        cfg.set_str("branch.main.remote", "origin").unwrap();
9554        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9555        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9556    }
9557
9558    #[test]
9559    fn evaluate_local_skips_a_detached_head() {
9560        let dir = tempfile::tempdir().unwrap();
9561        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9562        let head = repo.head().unwrap().target().unwrap();
9563        repo.set_head_detached(head).unwrap();
9564        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9565    }
9566
9567    #[test]
9568    fn evaluate_local_skips_a_non_github_remote() {
9569        let dir = tempfile::tempdir().unwrap();
9570        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9571        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9572    }
9573
9574    #[test]
9575    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9576        // A path git cannot discover a repo from is refused up front (defensive:
9577        // the UI only ever sends real worktrees). A nonexistent path is used so the
9578        // result never depends on whether the temp dir sits inside a checkout.
9579        assert_eq!(
9580            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9581                .unwrap_err()
9582                .kind,
9583            "not-a-repo"
9584        );
9585    }
9586
9587    #[test]
9588    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9589        // The audit line's `tracing` field expressions only evaluate when an INFO
9590        // subscriber is active — the sync helper makes that testable.
9591        let req = MergeQueueRequest {
9592            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9593            requester_key: Some("win-9".into()),
9594            check: true,
9595            confirmed: false,
9596        };
9597        let logs = capture_info(|| log_merge_check(&req, 1, 1));
9598        assert!(logs.contains("merge-queue check"), "{logs}");
9599        assert!(logs.contains("win-9"), "{logs}");
9600        assert!(logs.contains("requested=2"), "{logs}");
9601        assert!(logs.contains("eligible=1"), "{logs}");
9602    }
9603
9604    #[test]
9605    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9606        // A CLI-style requester (no window key) logs the dash fallback.
9607        let req = MergeQueueRequest {
9608            paths: vec![PathBuf::from("/a")],
9609            requester_key: None,
9610            check: false,
9611            confirmed: true,
9612        };
9613        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9614        assert!(logs.contains("merge-queue enqueue"), "{logs}");
9615        assert!(logs.contains("queued=2"), "{logs}");
9616        assert!(logs.contains("failed=1"), "{logs}");
9617    }
9618
9619    #[test]
9620    fn evaluate_local_flags_dirty_then_untracked() {
9621        // A linked worktree with a real checked-out file, so status is meaningful.
9622        let main_dir = tempfile::tempdir().unwrap();
9623        let repo = init_repo(main_dir.path());
9624        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9625        repo.set_head("refs/heads/main").unwrap();
9626        let wt_parent = tempfile::tempdir().unwrap();
9627        let wt_path = wt_parent.path().join("feature-wt");
9628        add_worktree(&repo, a, &wt_path, "feature");
9629
9630        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
9631        let clean = evaluate_local(&wt_path).unwrap_err();
9632        assert_ne!(clean.kind, "dirty");
9633        assert_ne!(clean.kind, "untracked");
9634
9635        // Modify the tracked file → dirty (gate 1, before any network call).
9636        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9637        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9638
9639        // Restore it, add a new file → untracked.
9640        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9641        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9642        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9643    }
9644
9645    #[test]
9646    fn is_conflicting_blocks_only_dirty_and_conflicting() {
9647        assert!(is_conflicting(Some("CONFLICTING")));
9648        assert!(is_conflicting(Some("DIRTY")));
9649        assert!(!is_conflicting(Some("CLEAN")));
9650        assert!(!is_conflicting(Some("BLOCKED")));
9651        assert!(!is_conflicting(Some("UNKNOWN")));
9652        assert!(!is_conflicting(None));
9653    }
9654
9655    #[test]
9656    fn merge_queue_request_parses_batch_and_phase_flags() {
9657        let req: MergeQueueRequest = serde_json::from_value(json!({
9658            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9659        }))
9660        .unwrap();
9661        assert_eq!(req.paths.len(), 2);
9662        assert_eq!(req.requester_key.as_deref(), Some("w1"));
9663        assert!(req.confirmed);
9664        assert!(!req.check);
9665        // Minimal payload: just paths; every other field defaults.
9666        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9667        assert!(req.paths.is_empty());
9668        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9669    }
9670
9671    #[test]
9672    fn queued_pr_omits_already_queued_when_false() {
9673        let v = serde_json::to_value(QueuedPr {
9674            path: "/a".into(),
9675            number: 5,
9676            already_queued: false,
9677        })
9678        .unwrap();
9679        assert!(v.get("already_queued").is_none(), "{v}");
9680        let v = serde_json::to_value(QueuedPr {
9681            path: "/a".into(),
9682            number: 5,
9683            already_queued: true,
9684        })
9685        .unwrap();
9686        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9687    }
9688
9689    #[tokio::test]
9690    async fn merge_queue_check_on_empty_selection_reports_nothing() {
9691        let svc = WorktreesService::new();
9692        let reply = svc
9693            .handle("merge-queue", json!({ "paths": [], "check": true }))
9694            .await
9695            .unwrap();
9696        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9697    }
9698
9699    #[tokio::test]
9700    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9701        // An unborn repo is skipped by the *local* gates, so the op never shells
9702        // `gh` — the check completes with no network stub.
9703        let dir = tempfile::tempdir().unwrap();
9704        let _repo = init_repo(dir.path());
9705        let svc = WorktreesService::new();
9706        let reply = svc
9707            .handle(
9708                "merge-queue",
9709                json!({ "paths": [dir.path()], "check": true }),
9710            )
9711            .await
9712            .unwrap();
9713        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9714        assert_eq!(skipped.len(), 1);
9715        assert_eq!(
9716            skipped[0].get("kind").and_then(Value::as_str),
9717            Some("no-commits")
9718        );
9719        assert!(reply
9720            .get("eligible")
9721            .and_then(Value::as_array)
9722            .unwrap()
9723            .is_empty());
9724    }
9725
9726    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
9727    /// that clears the local gates, so a test can drive the *network* gates by
9728    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
9729    fn ready_worktree() -> (tempfile::TempDir, String) {
9730        let dir = tempfile::tempdir().unwrap();
9731        let repo = pushed_github_repo(
9732            dir.path(),
9733            "https://github.com/rust-works/omni-dev.git",
9734            "feature",
9735        );
9736        let head = repo.head().unwrap().target().unwrap().to_string();
9737        (dir, head)
9738    }
9739
9740    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
9741    /// one check of `conclusion`, and one PR node `pr`.
9742    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9743        format!(
9744            r#"{{"data":{{"r0":{{"b0":{{
9745                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9746                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9747                ]}}}}}},
9748                "associatedPullRequests":{{"nodes":[{pr}]}}
9749            }}}}}}}}"#
9750        )
9751    }
9752
9753    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
9754    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
9755    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
9756    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9757        let ghdir = tempfile::tempdir().unwrap();
9758        let (bin, _shim) = fake_gh(ghdir.path(), reply);
9759        let paths = vec![head_dir.to_path_buf()];
9760        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9761        if let Some(e) = eligible.first() {
9762            return Ok(e.number);
9763        }
9764        Err(skipped.remove(0).kind)
9765    }
9766
9767    #[test]
9768    fn evaluate_batch_marks_a_ready_pr_eligible() {
9769        let (dir, head) = ready_worktree();
9770        let pr = format!(
9771            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9772        );
9773        assert_eq!(
9774            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9775            Ok(9)
9776        );
9777    }
9778
9779    #[test]
9780    fn evaluate_batch_skips_a_draft_pr() {
9781        let (dir, head) = ready_worktree();
9782        let pr = format!(
9783            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9784        );
9785        assert_eq!(
9786            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9787            Err("draft".to_string())
9788        );
9789    }
9790
9791    #[test]
9792    fn evaluate_batch_skips_a_conflicting_pr() {
9793        let (dir, head) = ready_worktree();
9794        let pr = format!(
9795            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9796        );
9797        assert_eq!(
9798            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9799            Err("conflicting".to_string())
9800        );
9801    }
9802
9803    #[test]
9804    fn evaluate_batch_skips_a_pr_with_failing_checks() {
9805        let (dir, head) = ready_worktree();
9806        let pr = format!(
9807            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9808        );
9809        assert_eq!(
9810            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9811            Err("checks-failing".to_string())
9812        );
9813    }
9814
9815    #[test]
9816    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9817        let (dir, head) = ready_worktree();
9818        // The remote PR head is a different commit than the local head.
9819        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9820        assert_eq!(
9821            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9822            Err("stale".to_string())
9823        );
9824    }
9825
9826    #[test]
9827    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9828        let (dir, head) = ready_worktree();
9829        // The ref resolves but no open PR heads it.
9830        let reply = format!(
9831            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9832        );
9833        assert_eq!(
9834            network_gate_outcome(dir.path(), &reply),
9835            Err("no-pr".to_string())
9836        );
9837    }
9838
9839    #[test]
9840    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9841        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
9842        // already-queued PR needs no mutation and is reported queued.
9843        let eligible = vec![
9844            Eligible {
9845                path: PathBuf::from("/wt/a"),
9846                number: 1,
9847                url: "u".into(),
9848                branch: "a".into(),
9849                pr_id: "PR_A".into(),
9850                already_queued: true,
9851            },
9852            Eligible {
9853                path: PathBuf::from("/wt/b"),
9854                number: 2,
9855                url: "u".into(),
9856                branch: "b".into(),
9857                pr_id: "PR_B".into(),
9858                already_queued: false,
9859            },
9860        ];
9861        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9862        assert_eq!(queued.len(), 1);
9863        assert_eq!(queued[0].number, 1);
9864        assert!(queued[0].already_queued);
9865        assert_eq!(failed.len(), 1);
9866        assert_eq!(failed[0].number, 2);
9867    }
9868
9869    #[test]
9870    fn enqueue_eligible_records_a_github_rejection_as_failed() {
9871        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
9872        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
9873        // rather than sinking the batch.
9874        let ghdir = tempfile::tempdir().unwrap();
9875        let (bin, _shim) = fake_gh(
9876            ghdir.path(),
9877            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9878        );
9879        let eligible = vec![Eligible {
9880            path: PathBuf::from("/wt/a"),
9881            number: 7,
9882            url: "u".into(),
9883            branch: "a".into(),
9884            pr_id: "PR_A".into(),
9885            already_queued: false,
9886        }];
9887        let (queued, failed) = enqueue_eligible(&bin, eligible);
9888        assert!(queued.is_empty(), "{queued:?}");
9889        assert_eq!(failed.len(), 1);
9890        assert_eq!(failed[0].number, 7);
9891        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
9892        // instead surface as an `Err`, still landing in `failed[]`).
9893        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9894    }
9895
9896    #[tokio::test]
9897    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9898    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9899        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
9900        // exercising the `PrRef` mapping the empty-selection tests cannot.
9901        let (dir, head) = ready_worktree();
9902        let pr = format!(
9903            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9904        );
9905        let ghdir = tempfile::tempdir().unwrap();
9906        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9907        let svc = WorktreesService::new();
9908        let req = MergeQueueRequest {
9909            paths: vec![dir.path().to_path_buf()],
9910            requester_key: None,
9911            check: true,
9912            confirmed: false,
9913        };
9914        // `merge_queue_with` shells the freshly-written `fake-gh` shim via
9915        // `spawn_blocking` — a test-only ETXTBSY exec race the `shim_lock`
9916        // guard alone does not retry (see test_support::shim's module docs).
9917        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9918            .await
9919            .unwrap();
9920        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9921        assert_eq!(eligible.len(), 1);
9922        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9923        assert_eq!(
9924            eligible[0].get("branch").and_then(Value::as_str),
9925            Some("feature")
9926        );
9927    }
9928
9929    #[tokio::test]
9930    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9931    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9932        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
9933        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
9934        let (dir, head) = ready_worktree();
9935        let pr = format!(
9936            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9937        );
9938        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9939        let ghdir = tempfile::tempdir().unwrap();
9940        let guard = shim_lock();
9941        let bin = ghdir.path().join("fake-gh");
9942        write_exec_script(
9943            &bin,
9944            &format!(
9945                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
9946                enqueue =
9947                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9948            ),
9949        );
9950        let svc = WorktreesService::new();
9951        let req = MergeQueueRequest {
9952            paths: vec![dir.path().to_path_buf()],
9953            requester_key: Some("w1".into()),
9954            check: false,
9955            confirmed: true,
9956        };
9957        // `merge_queue_with` shells the freshly-written `fake-gh` shim (twice,
9958        // for the resolve then the enqueue) via `spawn_blocking` — a test-only
9959        // ETXTBSY exec race the `shim_lock` guard alone does not retry (see
9960        // test_support::shim's module docs).
9961        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9962            .await
9963            .unwrap();
9964        drop(guard);
9965        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9966        assert_eq!(queued.len(), 1, "{reply}");
9967        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9968        assert!(reply
9969            .get("failed")
9970            .and_then(Value::as_array)
9971            .unwrap()
9972            .is_empty());
9973    }
9974
9975    #[tokio::test]
9976    async fn concurrent_closes_overlap_their_heartbeat_waits() {
9977        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9978        let svc = Arc::new(WorktreesService::new());
9979        // Two *different* windows own the two targets — the multi-select shape.
9980        for (key, path) in [("w2", &first), ("w3", &second)] {
9981            svc.handle("register", json!({ "key": key, "folders": [path] }))
9982                .await
9983                .unwrap();
9984        }
9985
9986        let spawn_close = |path: PathBuf| {
9987            let svc = svc.clone();
9988            tokio::spawn(async move {
9989                svc.handle(
9990                    "close",
9991                    json!({
9992                        "path": path,
9993                        "remove": true,
9994                        "confirmed": true,
9995                        "requester_key": "w1",
9996                    }),
9997                )
9998                .await
9999            })
10000        };
10001        let a = spawn_close(first.clone());
10002        let b = spawn_close(second.clone());
10003
10004        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
10005        // *both* windows are told to close while *neither* op has finished, so the
10006        // two multi-second heartbeat waits are in flight at once. Take the guard
10007        // before `await_windows_closed` instead of after and this fails — op B
10008        // would sit on the lock without ever marking w3, restoring exactly the
10009        // N-stacked-waits latency the fan-out exists to remove.
10010        for key in ["w2", "w3"] {
10011            let mut saw_close = false;
10012            for _ in 0..400 {
10013                let hb = svc
10014                    .handle("heartbeat", json!({ "key": key }))
10015                    .await
10016                    .unwrap();
10017                if hb.get("close").and_then(Value::as_bool) == Some(true) {
10018                    saw_close = true;
10019                    break;
10020                }
10021                tokio::time::sleep(Duration::from_millis(5)).await;
10022            }
10023            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10024        }
10025        assert!(
10026            !a.is_finished() && !b.is_finished(),
10027            "neither close can have finished: both windows are still registered"
10028        );
10029
10030        // Both windows close; both ops then remove.
10031        for key in ["w2", "w3"] {
10032            svc.handle("unregister", json!({ "key": key }))
10033                .await
10034                .unwrap();
10035        }
10036        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10037        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10038        assert!(!first.exists());
10039        assert!(!second.exists());
10040    }
10041
10042    #[tokio::test]
10043    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10044        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10045        let svc = WorktreesService::new();
10046        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
10047        // main, no risks → the extension proceeds with no dialog.
10048        let report = svc
10049            .handle("close", json!({ "path": wt_path, "remove": true }))
10050            .await
10051            .unwrap();
10052        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10053        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10054        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10055        assert!(report
10056            .get("risks")
10057            .and_then(Value::as_array)
10058            .unwrap()
10059            .is_empty());
10060        // No side effects: the worktree still exists.
10061        assert!(wt_path.exists());
10062    }
10063
10064    #[tokio::test]
10065    async fn close_removes_a_clean_linked_worktree() {
10066        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10067        let svc = WorktreesService::new();
10068        let reply = svc
10069            .handle(
10070                "close",
10071                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10072            )
10073            .await
10074            .unwrap();
10075        assert_eq!(reply, json!({ "removed": true }));
10076        assert!(
10077            !wt_path.exists(),
10078            "the worktree directory should be deleted"
10079        );
10080    }
10081
10082    // --- Close-op audit logging (#1364) ------------------------------------
10083
10084    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
10085    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
10086    /// `with_default`, so it never disturbs a global subscriber other tests set.
10087    /// The audit-line tests drive the sync helpers directly (no runtime, no
10088    /// `spawn_blocking`), so the captured events fire on this thread where the
10089    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
10090    /// under the parallel suite is *not* reliably captured this way.
10091    #[derive(Clone, Default)]
10092    struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10093
10094    impl std::io::Write for CaptureWriter {
10095        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10096            self.0.lock().unwrap().extend_from_slice(buf);
10097            Ok(buf.len())
10098        }
10099        fn flush(&mut self) -> std::io::Result<()> {
10100            Ok(())
10101        }
10102    }
10103
10104    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10105        type Writer = Self;
10106        fn make_writer(&'a self) -> Self::Writer {
10107            self.clone()
10108        }
10109    }
10110
10111    /// Runs `f` under a thread-local INFO-level subscriber and returns everything
10112    /// it logged. `f` must be fully synchronous on this thread.
10113    fn capture_info(f: impl FnOnce()) -> String {
10114        let writer = CaptureWriter::default();
10115        let subscriber = tracing_subscriber::fmt()
10116            .with_max_level(tracing::Level::INFO)
10117            .with_ansi(false)
10118            .with_writer(writer.clone())
10119            .finish();
10120        tracing::subscriber::with_default(subscriber, f);
10121        let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10122        logs
10123    }
10124
10125    // ── rebase op (#1415) ──────────────────────────────────────────────────
10126
10127    /// A `RebaseRequest` over `paths` with everything else defaulted.
10128    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10129        RebaseRequest {
10130            paths,
10131            requester_key: None,
10132            check: false,
10133            confirmed: false,
10134            keep_conflicts: false,
10135            autostash: false,
10136            onto: None,
10137        }
10138    }
10139
10140    /// A repo whose `main` has advanced one commit past a linked `feature`
10141    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
10142    ///
10143    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
10144    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
10145    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
10146    /// own concern and is covered in `worktree_rebase.rs`.
10147    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10148        let main_dir = tempfile::tempdir().unwrap();
10149        let repo = init_repo(main_dir.path());
10150        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10151        repo.set_head("refs/heads/main").unwrap();
10152        let wt_parent = tempfile::tempdir().unwrap();
10153        let wt_path = wt_parent.path().join("feature-wt");
10154        add_worktree(&repo, base, &wt_path, "feature");
10155        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
10156        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10157        (main_dir, wt_parent, wt_path)
10158    }
10159
10160    #[tokio::test]
10161    async fn rebase_with_refuses_an_empty_selection() {
10162        // A bare `rebase` must be a usage error, never a silent mass-rebase.
10163        let svc = WorktreesService::new();
10164        let err = svc
10165            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10166            .await
10167            .unwrap_err()
10168            .to_string();
10169        assert!(err.contains("at least one path"), "{err}");
10170    }
10171
10172    #[tokio::test]
10173    async fn rebase_with_phase_one_reports_without_rebasing() {
10174        let (_main, _parent, wt) = behind_worktree();
10175        let before = Repository::open(&wt).unwrap().head().unwrap().target();
10176
10177        let svc = WorktreesService::new();
10178        let reply = svc
10179            .rebase_with(
10180                RebaseRequest {
10181                    check: true,
10182                    onto: Some("main".into()),
10183                    ..rebase_req(vec![wt.clone()])
10184                },
10185                crate::git::resolve_git_binary(),
10186            )
10187            .await
10188            .unwrap();
10189
10190        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10191        assert_eq!(worktrees.len(), 1, "{reply}");
10192        assert_eq!(
10193            worktrees[0].get("status").and_then(Value::as_str),
10194            Some("would-rebase"),
10195            "{reply}"
10196        );
10197        // A local onto ref means no fetch was attempted at all.
10198        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10199        assert_eq!(fetches.len(), 1);
10200        assert_eq!(
10201            fetches[0].get("fetched").and_then(Value::as_bool),
10202            Some(false)
10203        );
10204        assert_eq!(
10205            Repository::open(&wt).unwrap().head().unwrap().target(),
10206            before,
10207            "phase 1 must not move the branch"
10208        );
10209    }
10210
10211    #[tokio::test]
10212    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10213        let (_main, _parent, wt) = behind_worktree();
10214        let svc = WorktreesService::new();
10215        let reply = svc
10216            .rebase_with(
10217                RebaseRequest {
10218                    confirmed: true,
10219                    onto: Some("main".into()),
10220                    ..rebase_req(vec![wt.clone()])
10221                },
10222                crate::git::resolve_git_binary(),
10223            )
10224            .await
10225            .unwrap();
10226
10227        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10228        assert_eq!(
10229            worktrees[0].get("status").and_then(Value::as_str),
10230            Some("rebased"),
10231            "{reply}"
10232        );
10233        // The transient cue is cleared on the way out, so no row keeps spinning.
10234        assert!(
10235            svc.registry.rebasing_paths().is_empty(),
10236            "the rebasing mark must be cleared after the execute"
10237        );
10238    }
10239
10240    #[tokio::test]
10241    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10242        // The re-validation that makes two-phase meaningful: a `confirmed` request
10243        // still runs the classifier, so a worktree that is dirty *now* is skipped
10244        // rather than rebased on the strength of an earlier phase-1 verdict.
10245        let (_main, _parent, wt) = behind_worktree();
10246        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10247
10248        let svc = WorktreesService::new();
10249        let reply = svc
10250            .rebase_with(
10251                RebaseRequest {
10252                    confirmed: true,
10253                    onto: Some("main".into()),
10254                    ..rebase_req(vec![wt.clone()])
10255                },
10256                crate::git::resolve_git_binary(),
10257            )
10258            .await
10259            .unwrap();
10260
10261        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10262        assert_eq!(
10263            worktrees[0].get("status").and_then(Value::as_str),
10264            Some("skipped"),
10265            "{reply}"
10266        );
10267        assert_eq!(
10268            worktrees[0].get("reason").and_then(Value::as_str),
10269            Some("dirty"),
10270            "{reply}"
10271        );
10272    }
10273
10274    #[tokio::test]
10275    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10276        // The hazard the plan-under-the-lock ordering exists to prevent: if another
10277        // run has left a worktree mid-rebase, this one must classify it as
10278        // `operation-in-progress` and leave it alone — never run `git rebase`
10279        // against it, which (without `keep_conflicts`) would `--abort` and destroy
10280        // the conflict resolution in progress.
10281        let (_main, _parent, wt) = behind_worktree();
10282        // Fake a rebase in progress the way git does: the state directory's
10283        // presence is what `Repository::state()` keys on.
10284        std::fs::create_dir_all(wt.join(".git")).ok();
10285        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10286        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10287        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10288        assert_ne!(
10289            Repository::open(&wt).unwrap().state(),
10290            RepositoryState::Clean,
10291            "precondition: the worktree looks mid-rebase to git2"
10292        );
10293
10294        let svc = WorktreesService::new();
10295        let reply = svc
10296            .rebase_with(
10297                RebaseRequest {
10298                    confirmed: true,
10299                    onto: Some("main".into()),
10300                    ..rebase_req(vec![wt.clone()])
10301                },
10302                crate::git::resolve_git_binary(),
10303            )
10304            .await
10305            .unwrap();
10306
10307        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10308        assert_eq!(
10309            worktrees[0].get("reason").and_then(Value::as_str),
10310            Some("operation-in-progress"),
10311            "{reply}"
10312        );
10313        // Still mid-rebase: nothing aborted it out from under whoever owns it.
10314        assert_ne!(
10315            Repository::open(&wt).unwrap().state(),
10316            RepositoryState::Clean
10317        );
10318    }
10319
10320    #[test]
10321    fn rebase_request_maps_onto_engine_options() {
10322        let req = RebaseRequest {
10323            keep_conflicts: true,
10324            autostash: true,
10325            onto: Some("origin/release".into()),
10326            ..rebase_req(vec![PathBuf::from("/wt")])
10327        };
10328        let opts = req.options(PathBuf::from("/custom/git"));
10329        assert!(opts.keep_conflicts && opts.autostash);
10330        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10331        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10332        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
10333        // engine's own flag stays off — setting it would be a second, redundant
10334        // gate that could silently no-op a confirmed execute.
10335        assert!(!opts.dry_run);
10336    }
10337
10338    #[test]
10339    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10340        let req = RebaseRequest {
10341            requester_key: Some("win-3".into()),
10342            check: true,
10343            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10344        };
10345        let plan = worktree_rebase::Plan {
10346            fetches: vec![worktree_rebase::FetchOutcome {
10347                repo_root: PathBuf::from("/repo"),
10348                onto: "origin/main".into(),
10349                fetched: true,
10350                ok: false,
10351                detail: Some("host unreachable".into()),
10352            }],
10353            worktrees: vec![
10354                worktree_rebase::WorktreeOutcome {
10355                    path: PathBuf::from("/a"),
10356                    branch: Some("a".into()),
10357                    onto: "origin/main".into(),
10358                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10359                },
10360                worktree_rebase::WorktreeOutcome {
10361                    path: PathBuf::from("/b"),
10362                    branch: Some("b".into()),
10363                    onto: "origin/main".into(),
10364                    result: worktree_rebase::RebaseResult::UpToDate,
10365                },
10366            ],
10367        };
10368        let logs = capture_info(|| log_rebase_check(&req, &plan));
10369        assert!(logs.contains("rebase check"), "{logs}");
10370        assert!(logs.contains("win-3"), "{logs}");
10371        assert!(logs.contains("requested=2"), "{logs}");
10372        assert!(logs.contains("pending=1"), "{logs}");
10373        assert!(logs.contains("failed_fetches=1"), "{logs}");
10374    }
10375
10376    #[test]
10377    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10378        // A CLI-style requester (no window key) logs the dash fallback.
10379        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10380        let outcome = |result| worktree_rebase::WorktreeOutcome {
10381            path: PathBuf::from("/x"),
10382            branch: Some("x".into()),
10383            onto: "origin/main".into(),
10384            result,
10385        };
10386        let outcomes = vec![
10387            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10388            outcome(worktree_rebase::RebaseResult::Conflict {
10389                detail: "CONFLICT".into(),
10390                left_in_place: true,
10391            }),
10392            outcome(worktree_rebase::RebaseResult::Skipped {
10393                reason: worktree_rebase::SkipReason::Dirty,
10394            }),
10395        ];
10396        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10397        assert!(logs.contains("rebase execute"), "{logs}");
10398        assert!(logs.contains("rebased=1"), "{logs}");
10399        assert!(logs.contains("conflicts=1"), "{logs}");
10400        assert!(logs.contains("left_in_place=1"), "{logs}");
10401        assert!(logs.contains("skipped=1"), "{logs}");
10402        assert!(logs.contains(r#"requester="-""#), "{logs}");
10403    }
10404
10405    // --- Push op (#1443) ---------------------------------------------------
10406
10407    /// A `PushRequest` with every field defaulted, for terse test construction.
10408    fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10409        PushRequest {
10410            paths,
10411            requester_key: None,
10412            check: false,
10413            confirmed: false,
10414        }
10415    }
10416
10417    /// A bare `origin`, a local clone of `main`, and a linked `feature` worktree
10418    /// whose branch is published and then **rewritten** — i.e. exactly what a
10419    /// rebase leaves behind, the state `push` exists for.
10420    ///
10421    /// Shells out to `git` (so the push has a real remote to talk to) under the
10422    /// shared serialization guard the other git-heavy tests use.
10423    fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10424        // Held for the fixture only — that dozen-subprocess burst is what the lock
10425        // exists to cap — and released on return, before any `.await` in the test.
10426        let _guard = crate::git::worktree_batch::test_serial_lock();
10427        let root = tempfile::tempdir().unwrap();
10428        let origin = root.path().join("origin.git");
10429        let local = root.path().join("local");
10430        let wt = root.path().join("feature-wt");
10431        std::fs::create_dir_all(&origin).unwrap();
10432        std::fs::create_dir_all(&local).unwrap();
10433
10434        let git = |dir: &Path, args: &[&str]| {
10435            let out = crate::git::worktree_batch::run_git_in(
10436                &crate::git::resolve_git_binary(),
10437                dir,
10438                args,
10439            )
10440            .unwrap();
10441            assert!(
10442                out.status.success(),
10443                "git {args:?} failed: {}",
10444                String::from_utf8_lossy(&out.stderr)
10445            );
10446        };
10447
10448        git(&origin, &["init", "--bare", "-b", "main"]);
10449        git(&local, &["init", "-b", "main"]);
10450        git(&local, &["config", "user.name", "Test"]);
10451        git(&local, &["config", "user.email", "test@example.com"]);
10452        git(&local, &["config", "commit.gpgsign", "false"]);
10453        std::fs::write(local.join("f.txt"), "base\n").unwrap();
10454        git(&local, &["add", "f.txt"]);
10455        git(&local, &["commit", "-m", "base"]);
10456        git(
10457            &local,
10458            &["remote", "add", "origin", origin.to_str().unwrap()],
10459        );
10460        git(&local, &["push", "-u", "origin", "main"]);
10461        git(
10462            &local,
10463            &[
10464                "worktree",
10465                "add",
10466                "-b",
10467                "feature",
10468                wt.to_str().unwrap(),
10469                "main",
10470            ],
10471        );
10472        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10473        git(&wt, &["add", "g.txt"]);
10474        git(&wt, &["commit", "-m", "work"]);
10475        git(&wt, &["push", "-u", "origin", "feature"]);
10476        // The rewrite: `feature` now diverges from `origin/feature`.
10477        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10478
10479        (root, origin, std::fs::canonicalize(&wt).unwrap())
10480    }
10481
10482    /// The tip of `refname` in the bare origin, when it exists.
10483    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10484        Repository::open_bare(origin)
10485            .unwrap()
10486            .refname_to_id(refname)
10487            .ok()
10488    }
10489
10490    #[tokio::test]
10491    async fn push_with_refuses_an_empty_selection() {
10492        // A bare `push` must be a usage error, never a silent mass-push.
10493        let svc = WorktreesService::new();
10494        let err = svc
10495            .push_with(push_req(Vec::new()), PathBuf::from("git"))
10496            .await
10497            .unwrap_err()
10498            .to_string();
10499        assert!(err.contains("at least one path"), "{err}");
10500    }
10501
10502    #[tokio::test]
10503    async fn push_with_phase_one_reports_without_publishing() {
10504        let (_root, origin, wt) = rewritten_worktree();
10505        let before = origin_tip(&origin, "refs/heads/feature");
10506
10507        let svc = WorktreesService::new();
10508        let reply = svc
10509            .push_with(
10510                PushRequest {
10511                    check: true,
10512                    ..push_req(vec![wt.clone()])
10513                },
10514                crate::git::resolve_git_binary(),
10515            )
10516            .await
10517            .unwrap();
10518
10519        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10520        assert_eq!(worktrees.len(), 1, "{reply}");
10521        assert_eq!(
10522            worktrees[0].get("status").and_then(Value::as_str),
10523            Some("would-force"),
10524            "{reply}"
10525        );
10526        assert!(
10527            reply.get("fetches").is_none(),
10528            "a push plan contacts no remote, so it reports no fetches: {reply}"
10529        );
10530        assert_eq!(
10531            origin_tip(&origin, "refs/heads/feature"),
10532            before,
10533            "phase 1 must publish nothing"
10534        );
10535        assert!(
10536            svc.registry.pushing_paths().is_empty(),
10537            "phase 1 must not mark a row as in flight"
10538        );
10539    }
10540
10541    #[tokio::test]
10542    async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10543        let (_root, origin, wt) = rewritten_worktree();
10544        let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10545
10546        let svc = WorktreesService::new();
10547        let reply = svc
10548            .push_with(
10549                PushRequest {
10550                    confirmed: true,
10551                    ..push_req(vec![wt.clone()])
10552                },
10553                crate::git::resolve_git_binary(),
10554            )
10555            .await
10556            .unwrap();
10557
10558        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10559        assert_eq!(
10560            worktrees[0].get("status").and_then(Value::as_str),
10561            Some("pushed"),
10562            "{reply}"
10563        );
10564        assert_eq!(
10565            worktrees[0].get("forced").and_then(Value::as_bool),
10566            Some(true),
10567            "a rewritten branch is published under the lease: {reply}"
10568        );
10569        assert_eq!(
10570            origin_tip(&origin, "refs/heads/feature"),
10571            rewritten,
10572            "the remote must carry the rewritten tip"
10573        );
10574        assert!(
10575            svc.registry.pushing_paths().is_empty(),
10576            "the pushing mark must be cleared after the execute — a push writes no \
10577             on-disk state, so nothing else could ever correct a leftover"
10578        );
10579    }
10580
10581    #[tokio::test]
10582    async fn push_resolves_the_git_binary_for_itself() {
10583        // The public entry point, which `push_with` exists to let the other tests
10584        // bypass. Safe to call for real: a `check` never reaches a subprocess at
10585        // all, because planning a push contacts no remote.
10586        let (_root, origin, wt) = rewritten_worktree();
10587        let before = origin_tip(&origin, "refs/heads/feature");
10588
10589        let svc = WorktreesService::new();
10590        let reply = svc
10591            .push(PushRequest {
10592                check: true,
10593                ..push_req(vec![wt])
10594            })
10595            .await
10596            .unwrap();
10597
10598        assert_eq!(
10599            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10600                .get("status")
10601                .and_then(Value::as_str),
10602            Some("would-force"),
10603            "{reply}"
10604        );
10605        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10606    }
10607
10608    #[tokio::test]
10609    async fn push_with_defaults_to_report_only_without_confirmation() {
10610        // Neither `check` nor `confirmed`: the safe reading is "report", matching
10611        // `rebase`. A client that forgets the flag must never publish.
10612        let (_root, origin, wt) = rewritten_worktree();
10613        let before = origin_tip(&origin, "refs/heads/feature");
10614
10615        let svc = WorktreesService::new();
10616        let reply = svc
10617            .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10618            .await
10619            .unwrap();
10620
10621        assert_eq!(
10622            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10623                .get("status")
10624                .and_then(Value::as_str),
10625            Some("would-force"),
10626            "{reply}"
10627        );
10628        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10629    }
10630
10631    #[tokio::test]
10632    async fn push_with_refuses_to_force_the_remote_default_branch() {
10633        // The gate that inverts ADR-0060, enforced in the daemon rather than only
10634        // in the UI: a rewritten `main` is reported, never published.
10635        let (root, origin, _wt) = rewritten_worktree();
10636        let local = root.path().join("local");
10637        let before = origin_tip(&origin, "refs/heads/main");
10638        crate::git::worktree_batch::run_git_in(
10639            &crate::git::resolve_git_binary(),
10640            &local,
10641            &["commit", "--amend", "-m", "rewritten main"],
10642        )
10643        .unwrap();
10644
10645        let svc = WorktreesService::new();
10646        let reply = svc
10647            .push_with(
10648                PushRequest {
10649                    confirmed: true,
10650                    ..push_req(vec![local.clone()])
10651                },
10652                crate::git::resolve_git_binary(),
10653            )
10654            .await
10655            .unwrap();
10656
10657        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10658        assert_eq!(
10659            worktrees[0].get("reason").and_then(Value::as_str),
10660            Some("default-branch-force-push"),
10661            "{reply}"
10662        );
10663        assert_eq!(
10664            origin_tip(&origin, "refs/heads/main"),
10665            before,
10666            "the default branch's published history must be untouched"
10667        );
10668    }
10669
10670    #[test]
10671    fn log_push_check_separates_the_force_count_from_the_pending_count() {
10672        let req = PushRequest {
10673            requester_key: Some("win-7".into()),
10674            check: true,
10675            ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10676        };
10677        let outcome = |result| worktree_push::WorktreeOutcome {
10678            path: PathBuf::from("/x"),
10679            branch: Some("x".into()),
10680            remote: "origin".into(),
10681            remote_branch: "x".into(),
10682            result,
10683        };
10684        let plan = worktree_push::Plan {
10685            worktrees: vec![
10686                outcome(worktree_push::PushResult::WouldForce {
10687                    ahead: 1,
10688                    behind: 1,
10689                }),
10690                outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10691                outcome(worktree_push::PushResult::Skipped {
10692                    reason: worktree_push::SkipReason::DefaultBranchForcePush,
10693                }),
10694            ],
10695        };
10696        let logs = capture_info(|| log_push_check(&req, &plan));
10697        assert!(logs.contains("push check"), "{logs}");
10698        assert!(logs.contains("pending=2"), "{logs}");
10699        assert!(logs.contains("forced=1"), "{logs}");
10700        assert!(logs.contains("skipped=1"), "{logs}");
10701        assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10702    }
10703
10704    #[test]
10705    fn log_push_execute_counts_lease_refusals_separately() {
10706        let req = push_req(vec![PathBuf::from("/a")]);
10707        let outcome = |result| worktree_push::WorktreeOutcome {
10708            path: PathBuf::from("/x"),
10709            branch: Some("x".into()),
10710            remote: "origin".into(),
10711            remote_branch: "x".into(),
10712            result,
10713        };
10714        let outcomes = vec![
10715            outcome(worktree_push::PushResult::Pushed { forced: true }),
10716            outcome(worktree_push::PushResult::Pushed { forced: false }),
10717            outcome(worktree_push::PushResult::Created),
10718            outcome(worktree_push::PushResult::Rejected {
10719                detail: "stale info".into(),
10720                stale: true,
10721            }),
10722            outcome(worktree_push::PushResult::Rejected {
10723                detail: "pre-receive hook declined".into(),
10724                stale: false,
10725            }),
10726        ];
10727        let logs = capture_info(|| log_push_execute(&req, &outcomes));
10728        assert!(logs.contains("push execute"), "{logs}");
10729        assert!(logs.contains("pushed=2"), "{logs}");
10730        assert!(logs.contains("forced=1"), "{logs}");
10731        assert!(logs.contains("created=1"), "{logs}");
10732        assert!(logs.contains("rejected=2"), "{logs}");
10733        assert!(
10734            logs.contains("stale_rejected=1"),
10735            "a lease refusal is the interesting half of a rejection: {logs}"
10736        );
10737    }
10738
10739    #[test]
10740    fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10741        let dir = tempfile::tempdir().unwrap();
10742        let path = canonical(dir.path());
10743
10744        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10745        assert!(!quiet.pushing);
10746        let json = serde_json::to_value(&quiet).unwrap();
10747        assert!(
10748            json.get("pushing").is_none(),
10749            "an idle row stays byte-identical for an older client: {json}"
10750        );
10751
10752        let busy = worktree_entry(
10753            &path,
10754            true,
10755            &HashMap::new(),
10756            &InFlight {
10757                pushing: [path.clone()].into(),
10758                rebasing: HashSet::new(),
10759            },
10760        );
10761        assert!(busy.pushing, "the registry's transient mark rides through");
10762        assert!(
10763            !busy.rebasing,
10764            "the two cues are independent — a push must not read as a rebase"
10765        );
10766        assert_eq!(
10767            serde_json::to_value(&busy).unwrap()["pushing"],
10768            serde_json::json!(true)
10769        );
10770    }
10771
10772    #[test]
10773    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10774        assert_eq!(operation_slug(RepositoryState::Clean), None);
10775        assert_eq!(
10776            operation_slug(RepositoryState::Rebase).as_deref(),
10777            Some("rebase")
10778        );
10779        assert_eq!(
10780            operation_slug(RepositoryState::RebaseMerge).as_deref(),
10781            Some("rebase"),
10782            "the merge-backend rebase is still just a rebase to the user"
10783        );
10784        assert_eq!(
10785            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10786            Some("rebase-interactive")
10787        );
10788        assert_eq!(
10789            operation_slug(RepositoryState::Merge).as_deref(),
10790            Some("merge")
10791        );
10792        assert_eq!(
10793            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10794            Some("cherry-pick")
10795        );
10796        assert_eq!(
10797            operation_slug(RepositoryState::RevertSequence).as_deref(),
10798            Some("revert")
10799        );
10800        assert_eq!(
10801            operation_slug(RepositoryState::Bisect).as_deref(),
10802            Some("bisect")
10803        );
10804        assert_eq!(
10805            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10806            Some("apply-mailbox")
10807        );
10808    }
10809
10810    #[test]
10811    fn git_status_omits_operation_for_a_clean_worktree() {
10812        let dir = tempfile::tempdir().unwrap();
10813        let _repo = diverging_repo(dir.path());
10814        assert_eq!(
10815            git_status(dir.path()).operation,
10816            None,
10817            "a clean worktree carries no operation, so the field stays off the wire"
10818        );
10819    }
10820
10821    #[test]
10822    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10823        let main_dir = tempfile::tempdir().unwrap();
10824        let repo = init_repo(main_dir.path());
10825        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10826        repo.set_head("refs/heads/main").unwrap();
10827        let path = canonical(main_dir.path());
10828
10829        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10830        assert!(!quiet.rebasing);
10831        // Byte-identical for a pre-#1415 client: neither new field is serialized.
10832        let json = serde_json::to_value(&quiet).unwrap();
10833        assert!(json.get("rebasing").is_none(), "{json}");
10834        assert!(json.get("operation").is_none(), "{json}");
10835
10836        let busy = worktree_entry(
10837            &path,
10838            true,
10839            &HashMap::new(),
10840            &InFlight {
10841                rebasing: std::iter::once(path.clone()).collect(),
10842                pushing: HashSet::new(),
10843            },
10844        );
10845        assert!(busy.rebasing, "the registry's transient mark rides through");
10846        assert_eq!(
10847            serde_json::to_value(&busy).unwrap()["rebasing"],
10848            serde_json::Value::Bool(true)
10849        );
10850    }
10851
10852    #[test]
10853    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10854        assert_eq!(note_kinds(&[]), "-");
10855        assert_eq!(
10856            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10857            "dirty,untracked"
10858        );
10859    }
10860
10861    #[test]
10862    fn is_self_close_true_only_when_requester_owns_an_open_window() {
10863        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10864        assert!(is_self_close(Some("w1"), &windows));
10865        assert!(
10866            !is_self_close(Some("w3"), &windows),
10867            "requester owns no window"
10868        );
10869        assert!(!is_self_close(None, &windows), "no requester");
10870        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10871    }
10872
10873    #[test]
10874    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10875        let logs = capture_info(|| {
10876            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10877            assert_eq!(reply, json!({ "removed": true }));
10878        });
10879        assert!(
10880            logs.contains("worktrees close: linked worktree pruned"),
10881            "a successful prune must log an INFO audit line, got: {logs}"
10882        );
10883        assert!(
10884            logs.contains("/wt/feature"),
10885            "the target path must ride the line, got: {logs}"
10886        );
10887    }
10888
10889    #[test]
10890    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10891        // The #1403 fix: an already-removed worktree still replies `removed: true`
10892        // (the row should go) but must NOT log the `pruned` line — it logs the
10893        // distinct `already-gone` outcome so the audit trail stops conflating the
10894        // two.
10895        let logs = capture_info(|| {
10896            let reply =
10897                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10898            assert_eq!(reply, json!({ "removed": true }));
10899        });
10900        assert!(
10901            logs.contains("worktrees close: nothing to prune, worktree already removed"),
10902            "an already-gone close must log its own outcome, got: {logs}"
10903        );
10904        assert!(
10905            !logs.contains("linked worktree pruned"),
10906            "an already-gone close must not claim it pruned, got: {logs}"
10907        );
10908    }
10909
10910    #[test]
10911    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10912        // ERROR is more severe than the INFO cap, so `capture_info` records it.
10913        let logs = capture_info(|| {
10914            let err = log_close_error(
10915                Path::new("/wt/feature"),
10916                "safety check",
10917                anyhow!("not a git worktree"),
10918            );
10919            assert_eq!(
10920                err.to_string(),
10921                "not a git worktree",
10922                "err propagates unchanged"
10923            );
10924        });
10925        assert!(
10926            logs.contains("worktrees close: safety check failed"),
10927            "a failed phase must log an ERROR audit line, got: {logs}"
10928        );
10929        assert!(
10930            logs.contains("not a git worktree"),
10931            "the cause must ride the line, got: {logs}"
10932        );
10933        assert!(
10934            logs.contains("/wt/feature"),
10935            "the target path must ride the line, got: {logs}"
10936        );
10937    }
10938
10939    #[test]
10940    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10941        let logs = capture_info(|| {
10942            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10943            assert!(err.is_err(), "a prune failure must propagate");
10944        });
10945        assert!(
10946            logs.contains("worktrees close: worktree prune failed"),
10947            "a prune failure must log a WARN audit line, got: {logs}"
10948        );
10949        assert!(
10950            logs.contains("locked"),
10951            "the failure cause must ride the line, got: {logs}"
10952        );
10953    }
10954
10955    #[test]
10956    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10957        let git = GitSafety {
10958            is_main: false,
10959            removable: true,
10960            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10961            info: vec![],
10962        };
10963        let logs = capture_info(|| {
10964            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10965        });
10966        assert!(
10967            logs.contains("worktrees close: safety check"),
10968            "phase-1 must log a safety-check line, got: {logs}"
10969        );
10970        assert!(
10971            logs.contains("/wt/feature"),
10972            "the path must ride the line, got: {logs}"
10973        );
10974        assert!(
10975            logs.contains("window_key=\"win-42\""),
10976            "the owning window key must ride the line, got: {logs}"
10977        );
10978        assert!(logs.contains("removable=true"), "got: {logs}");
10979        assert!(logs.contains("is_main=false"), "got: {logs}");
10980        assert!(logs.contains("open=true"), "got: {logs}");
10981        assert!(
10982            logs.contains("risks=dirty,untracked"),
10983            "the blocking risk kinds must ride the line, got: {logs}"
10984        );
10985    }
10986
10987    #[test]
10988    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10989        let git = GitSafety {
10990            is_main: false,
10991            removable: true,
10992            risks: vec![],
10993            info: vec![],
10994        };
10995        let logs = capture_info(|| {
10996            log_safety_check(Path::new("/wt/feature"), None, &git, false);
10997        });
10998        assert!(
10999            logs.contains("window_key=\"-\""),
11000            "no owning window → dash, got: {logs}"
11001        );
11002        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11003    }
11004
11005    #[test]
11006    fn log_executing_logs_the_routing_decision() {
11007        let logs = capture_info(|| {
11008            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11009        });
11010        assert!(
11011            logs.contains("worktrees close: executing"),
11012            "phase-2 must log the execute routing, got: {logs}"
11013        );
11014        assert!(
11015            logs.contains("requester=\"win-7\""),
11016            "the requester key must ride the line, got: {logs}"
11017        );
11018        assert!(logs.contains("remove=true"), "got: {logs}");
11019        assert!(logs.contains("self_close=false"), "got: {logs}");
11020        assert!(logs.contains("cross_window=3"), "got: {logs}");
11021    }
11022
11023    #[test]
11024    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11025        let logs = capture_info(|| {
11026            log_close_abort(
11027                Path::new("/wt/feature"),
11028                &anyhow!("window(s) did not close in time: win-9"),
11029            );
11030        });
11031        assert!(
11032            logs.contains("worktrees close: aborted"),
11033            "an abort must log a WARN audit line, got: {logs}"
11034        );
11035        assert!(
11036            logs.contains("/wt/feature"),
11037            "the path must ride the line, got: {logs}"
11038        );
11039        assert!(
11040            logs.contains("win-9"),
11041            "the still-open window must ride the line, got: {logs}"
11042        );
11043    }
11044
11045    #[test]
11046    fn log_window_closed_logs_the_no_removal_outcome() {
11047        let logs = capture_info(|| {
11048            log_window_closed(Path::new("/wt/feature"));
11049        });
11050        assert!(
11051            logs.contains("worktrees close: window closed, no removal"),
11052            "a remove:false close must log the no-removal outcome, got: {logs}"
11053        );
11054        assert!(
11055            logs.contains("/wt/feature"),
11056            "the path must ride the line, got: {logs}"
11057        );
11058    }
11059
11060    #[test]
11061    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11062        // The reorder (#1315) must still fully remove a worktree: both the
11063        // checked-out directory *and* the admin metadata git tracks it by, so it
11064        // no longer appears in `Repository::worktrees()`.
11065        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11066        let admin = main.path().join(".git").join("worktrees").join("feature");
11067        assert!(admin.exists(), "admin metadata should exist before removal");
11068
11069        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11070
11071        assert!(!wt_path.exists(), "the working directory should be gone");
11072        assert!(!admin.exists(), "the admin metadata should be pruned");
11073        let main_repo = Repository::open(main.path()).unwrap();
11074        assert_eq!(
11075            main_repo.worktrees().unwrap().len(),
11076            0,
11077            "git should no longer track the worktree"
11078        );
11079    }
11080
11081    #[test]
11082    fn remove_worktree_recovers_a_half_removed_orphan() {
11083        // The exact #1315 leftover: the old ordering deleted the admin metadata
11084        // first, then failed to rmdir the working tree, orphaning the directory
11085        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
11086        // rather than error with "not a git worktree".
11087        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11088        let admin = main.path().join(".git").join("worktrees").join("feature");
11089        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
11090        std::fs::remove_dir_all(&admin).unwrap();
11091        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11092        assert!(
11093            Repository::open(&wt_path).is_err(),
11094            "the orphan should not open as a repo"
11095        );
11096
11097        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11098        assert!(
11099            !wt_path.exists(),
11100            "the leftover directory should be removed"
11101        );
11102    }
11103
11104    /// A linked worktree whose working directory has been deleted out-of-band,
11105    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
11106    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
11107    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
11108    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
11109    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
11110    /// admin dir)`.
11111    fn orphaned_admin_worktree() -> (
11112        tempfile::TempDir,
11113        PathBuf,
11114        tempfile::TempDir,
11115        PathBuf,
11116        PathBuf,
11117    ) {
11118        let main_dir = tempfile::tempdir().unwrap();
11119        let main_root = main_dir.path().canonicalize().unwrap();
11120        let repo = init_repo(&main_root);
11121        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11122        repo.set_head("refs/heads/trunk").unwrap();
11123        let wt_parent = tempfile::tempdir().unwrap();
11124        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11125        add_worktree(&repo, a, &wt_path, "feature");
11126        let admin = main_root.join(".git").join("worktrees").join("feature");
11127        assert!(admin.exists(), "admin metadata exists before the orphaning");
11128        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
11129        std::fs::remove_dir_all(&wt_path).unwrap();
11130        (main_dir, main_root, wt_parent, wt_path, admin)
11131    }
11132
11133    fn window_on(folder: &Path) -> WindowEntry {
11134        WindowEntry {
11135            key: "w".to_string(),
11136            folders: vec![folder.to_path_buf()],
11137            repo: None,
11138            title: None,
11139            pid: None,
11140            last_seen: Utc::now(),
11141        }
11142    }
11143
11144    #[test]
11145    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11146        // #1403: working tree gone, admin present. An external worktree shares no
11147        // ancestor with its repo, so the owner is found via a live window on the
11148        // main repo — the same window whose repo enumeration showed the stuck row.
11149        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11150
11151        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11152
11153        assert_eq!(
11154            removed,
11155            Removal::Pruned,
11156            "the orphaned admin must be pruned"
11157        );
11158        assert!(!admin.exists(), "the admin metadata should be gone");
11159        let main_repo = Repository::open(&main_root).unwrap();
11160        assert!(
11161            main_repo.worktrees().unwrap().is_empty(),
11162            "git should no longer track the orphaned worktree"
11163        );
11164    }
11165
11166    #[test]
11167    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11168        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
11169        // worktrees/<name>` shape that produced the reported orphans) is located
11170        // by walking the gone path's ancestors — no registered window needed.
11171        let main_dir = tempfile::tempdir().unwrap();
11172        let main_root = main_dir.path().canonicalize().unwrap();
11173        let repo = init_repo(&main_root);
11174        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11175        repo.set_head("refs/heads/trunk").unwrap();
11176        // git2's `worktree()` creates the leaf but not intermediate parents.
11177        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11178        let wt_path = main_root.join(".nested").join("feature-wt");
11179        add_worktree(&repo, a, &wt_path, "feature");
11180        let admin = main_root.join(".git").join("worktrees").join("feature");
11181        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11182
11183        let removed = remove_worktree(&wt_path, &[]).unwrap();
11184
11185        assert_eq!(removed, Removal::Pruned);
11186        assert!(!admin.exists(), "the admin metadata should be gone");
11187    }
11188
11189    #[test]
11190    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11191        // The other side of #1403: working tree gone AND admin already pruned. No
11192        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
11193        // never a `pruned` lie.
11194        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11195        // Prune the admin entry too, so nothing remains to remove.
11196        std::fs::remove_dir_all(&admin).unwrap();
11197
11198        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11199
11200        assert_eq!(removed, Removal::AlreadyGone);
11201    }
11202
11203    #[test]
11204    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11205        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11206        // External worktree: no ancestor is the repo, so only the window feed finds
11207        // it. The main root rides through, deduped to a single entry.
11208        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11209        assert!(
11210            roots.contains(&main_root),
11211            "the owning main repo must be a candidate, got: {roots:?}"
11212        );
11213    }
11214
11215    #[test]
11216    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11217        // A candidate root that does not open as a repo (a stale registry folder,
11218        // a deleted repo) is skipped rather than fatal; the real owner that
11219        // follows still prunes.
11220        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11221        let junk = tempfile::tempdir().unwrap();
11222
11223        let removed =
11224            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11225
11226        assert_eq!(removed, Removal::Pruned);
11227        assert!(
11228            !admin.exists(),
11229            "the real owner must still prune the orphan"
11230        );
11231    }
11232
11233    #[test]
11234    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11235        // A candidate that opens as a repo but is a *linked* worktree carries no
11236        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
11237        // is the real owner. (A live sibling worktree is exactly such a candidate.)
11238        let main_dir = tempfile::tempdir().unwrap();
11239        let main_root = main_dir.path().canonicalize().unwrap();
11240        let repo = init_repo(&main_root);
11241        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11242        repo.set_head("refs/heads/trunk").unwrap();
11243        let wt_parent = tempfile::tempdir().unwrap();
11244        let wt_root = wt_parent.path().canonicalize().unwrap();
11245        let orphan = wt_root.join("orphan-wt");
11246        let sibling = wt_root.join("sibling-wt");
11247        add_worktree(&repo, a, &orphan, "orphan");
11248        add_worktree(&repo, a, &sibling, "sibling");
11249        let admin = main_root.join(".git").join("worktrees").join("orphan");
11250        std::fs::remove_dir_all(&orphan).unwrap();
11251
11252        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
11253        // main repo second (the actual owner).
11254        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11255
11256        assert_eq!(removed, Removal::Pruned);
11257        assert!(
11258            !admin.exists(),
11259            "the orphan's admin metadata must be pruned"
11260        );
11261    }
11262
11263    #[test]
11264    fn prune_orphaned_admin_refuses_a_locked_orphan() {
11265        // Locking is an admin-dir file, independent of the (gone) checkout, so an
11266        // orphaned worktree can still be locked. The prune must refuse it —
11267        // "unlock first" — rather than force past, mirroring the live path.
11268        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11269        let main_repo = Repository::open(&main_root).unwrap();
11270        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11271        main_repo
11272            .find_worktree(&name)
11273            .unwrap()
11274            .lock(Some("in use"))
11275            .unwrap();
11276
11277        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11278
11279        assert!(
11280            err.to_string().contains("locked"),
11281            "a locked orphan must be refused, got: {err:#}"
11282        );
11283        assert!(admin.exists(), "a refused prune must leave the admin entry");
11284    }
11285
11286    #[test]
11287    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11288        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11289        // A live worktree: gitlink resolves → not an orphan.
11290        assert!(!is_orphaned_worktree(&wt_path));
11291        // The main checkout has a `.git` directory → not an orphan.
11292        assert!(!is_orphaned_worktree(main.path()));
11293        // Drop the admin metadata → the gitlink now dangles → orphan.
11294        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11295            .unwrap();
11296        assert!(is_orphaned_worktree(&wt_path));
11297    }
11298
11299    #[test]
11300    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11301        let tmp = tempfile::tempdir().unwrap();
11302        let missing = tmp.path().join("gone");
11303        assert!(remove_dir_all_retrying(&missing).is_ok());
11304    }
11305
11306    #[test]
11307    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11308        use std::io::Error;
11309        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11310            assert!(
11311                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11312                "errno {errno} is the concurrent-writer race and must be retried"
11313            );
11314        }
11315        // A hard failure must surface immediately rather than burn the backoff
11316        // waiting for a condition that will never clear.
11317        for errno in [
11318            nix::libc::EACCES,
11319            nix::libc::EPERM,
11320            nix::libc::EROFS,
11321            nix::libc::ENOTDIR,
11322        ] {
11323            assert!(
11324                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11325                "errno {errno} is permanent and must not be retried"
11326            );
11327        }
11328        // Not from the OS at all, so there is no errno to classify.
11329        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11330    }
11331
11332    #[test]
11333    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11334        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
11335        // race, so it must fail on the first attempt with the original cause
11336        // attached, leaving the path untouched.
11337        let tmp = tempfile::tempdir().unwrap();
11338        let file = tmp.path().join("not-a-directory");
11339        std::fs::write(&file, b"x").unwrap();
11340
11341        let mut attempts = 0;
11342        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11343            attempts += 1;
11344            std::fs::remove_dir_all(&file)
11345        })
11346        .unwrap_err();
11347
11348        assert_eq!(attempts, 1, "a permanent error must not be retried");
11349        assert!(
11350            err.to_string()
11351                .contains("failed to remove worktree directory"),
11352            "unexpected error: {err:#}"
11353        );
11354        assert!(err.source().is_some(), "the io::Error cause is preserved");
11355        assert!(file.exists());
11356    }
11357
11358    #[test]
11359    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11360        // A writer that never quiesces: every sweep re-finds the directory
11361        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
11362        // than the loop spinning forever.
11363        let tmp = tempfile::tempdir().unwrap();
11364        let mut attempts = 0;
11365        let backoff = [Duration::ZERO, Duration::ZERO];
11366        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11367            attempts += 1;
11368            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11369        })
11370        .unwrap_err();
11371
11372        // One attempt per delay, plus the initial one.
11373        assert_eq!(attempts, backoff.len() + 1);
11374        assert!(
11375            err.to_string()
11376                .contains("failed to remove worktree directory"),
11377            "unexpected error: {err:#}"
11378        );
11379    }
11380
11381    #[test]
11382    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11383        // The #1315 happy path, deterministically: the race clears partway through
11384        // the schedule and the removal then succeeds.
11385        let tmp = tempfile::tempdir().unwrap();
11386        let mut attempts = 0;
11387        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11388            attempts += 1;
11389            if attempts < 3 {
11390                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11391            } else {
11392                Ok(())
11393            }
11394        });
11395        assert!(result.is_ok(), "{result:?}");
11396        assert_eq!(attempts, 3);
11397    }
11398
11399    #[test]
11400    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11401        // A `.git` file that is readable but carries no `gitdir:` pointer is not
11402        // something we may delete.
11403        let tmp = tempfile::tempdir().unwrap();
11404        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11405        assert!(!is_orphaned_worktree(tmp.path()));
11406    }
11407
11408    #[test]
11409    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11410        // Neither a repo nor an orphan: refuse it rather than recursively deleting
11411        // whatever directory was passed in.
11412        let tmp = tempfile::tempdir().unwrap();
11413        let plain = tmp.path().join("plain");
11414        std::fs::create_dir(&plain).unwrap();
11415
11416        let err = remove_worktree(&plain, &[]).unwrap_err();
11417
11418        assert!(
11419            err.to_string().contains("not a git worktree"),
11420            "unexpected error: {err:#}"
11421        );
11422        assert!(plain.exists(), "a non-worktree path must be left alone");
11423    }
11424
11425    #[test]
11426    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11427        // Acceptance criterion (#1315): a language server / cargo still writing
11428        // into `target/` as the window closes makes the recursive rmdir race with
11429        // "Directory not empty". A background thread reproduces that by
11430        // repopulating `target/` for a bounded window; removal must retry past it
11431        // and still succeed once the writer stops.
11432        use std::sync::atomic::{AtomicBool, Ordering};
11433        use std::sync::Arc;
11434
11435        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11436        // Created once, here — never inside the writer loop. `create_dir_all`
11437        // rebuilds every *parent* component, so calling it per iteration let the
11438        // writer resurrect the worktree root the instant removal won the race,
11439        // failing the final assertion on a directory removal had correctly
11440        // deleted and the test itself put back (#1410).
11441        let nested = wt_path.join("target").join("nested");
11442        std::fs::create_dir_all(&nested).unwrap();
11443
11444        let stop = Arc::new(AtomicBool::new(false));
11445        let writer_stop = Arc::clone(&stop);
11446        let writer_dir = nested;
11447        let writer = std::thread::spawn(move || {
11448            let mut n = 0u64;
11449            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
11450            // stop so a later removal pass finds the directory quiescent.
11451            let deadline = std::time::Instant::now() + Duration::from_millis(400);
11452            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11453                // Best-effort, and deliberately creating no directory: `fs::write`
11454                // is `File::create`, which never makes parents. While `target/`
11455                // survives these keep it non-empty — the ENOTEMPTY removal has to
11456                // retry past — and once removal wins they simply fail with ENOENT.
11457                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11458                n += 1;
11459            }
11460        });
11461
11462        let result = remove_worktree(&wt_path, &[]);
11463        stop.store(true, Ordering::Relaxed);
11464        writer.join().unwrap();
11465
11466        assert!(
11467            result.is_ok(),
11468            "removal should retry past the writer: {result:?}"
11469        );
11470        assert!(!wt_path.exists(), "the worktree directory should be gone");
11471    }
11472
11473    #[tokio::test]
11474    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11475        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11476        // An untracked file in the worktree would be lost on removal.
11477        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11478
11479        let svc = WorktreesService::new();
11480        let report = svc
11481            .handle("close", json!({ "path": wt_path, "remove": true }))
11482            .await
11483            .unwrap();
11484        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11485        assert!(
11486            risks
11487                .iter()
11488                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11489            "expected an untracked risk: {report}"
11490        );
11491        // Still removable — the risk only means "confirm first", not "refuse".
11492        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11493        // The unconfirmed check has no side effects.
11494        assert!(wt_path.exists());
11495    }
11496
11497    #[tokio::test]
11498    async fn close_confirmed_removes_a_dirty_worktree() {
11499        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11500        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11501        let svc = WorktreesService::new();
11502        // With confirmation, the risks are overridden and removal proceeds.
11503        let reply = svc
11504            .handle(
11505                "close",
11506                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11507            )
11508            .await
11509            .unwrap();
11510        assert_eq!(reply, json!({ "removed": true }));
11511        assert!(!wt_path.exists());
11512    }
11513
11514    #[tokio::test]
11515    async fn close_refuses_to_remove_the_main_working_tree() {
11516        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11517        let svc = WorktreesService::new();
11518        // Phase 1: the main tree reports not-removable, marked main.
11519        let report = svc
11520            .handle("close", json!({ "path": main.path(), "remove": true }))
11521            .await
11522            .unwrap();
11523        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11524        assert_eq!(
11525            report.get("removable").and_then(Value::as_bool),
11526            Some(false)
11527        );
11528        // Phase 2: even a confirmed delete of the main tree is refused
11529        // defensively, and the directory is untouched.
11530        assert!(svc
11531            .handle(
11532                "close",
11533                json!({ "path": main.path(), "remove": true, "confirmed": true }),
11534            )
11535            .await
11536            .is_err());
11537        assert!(main.path().exists());
11538    }
11539
11540    #[tokio::test]
11541    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11542        // The case a naive impl would wrongly protect: a linked worktree checked
11543        // out on `main` (the default branch) is *still a linked worktree*, so it
11544        // is fully deletable — and `main` survives (removal never deletes a branch).
11545        let main_dir = tempfile::tempdir().unwrap();
11546        let repo = init_repo(main_dir.path());
11547        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11548        repo.set_head("refs/heads/trunk").unwrap();
11549        let wt_parent = tempfile::tempdir().unwrap();
11550        let wt_path = wt_parent.path().join("main-wt");
11551        add_worktree(&repo, a, &wt_path, "main");
11552
11553        let svc = WorktreesService::new();
11554        let reply = svc
11555            .handle(
11556                "close",
11557                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11558            )
11559            .await
11560            .unwrap();
11561        assert_eq!(reply, json!({ "removed": true }));
11562        assert!(!wt_path.exists());
11563        // The `main` branch is untouched by the worktree removal.
11564        assert!(
11565            repo.find_branch("main", git2::BranchType::Local).is_ok(),
11566            "the default branch must survive worktree removal"
11567        );
11568    }
11569
11570    #[tokio::test]
11571    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11572        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11573        let svc = WorktreesService::new();
11574        // First removal succeeds.
11575        svc.handle(
11576            "close",
11577            json!({ "path": wt_path, "remove": true, "confirmed": true }),
11578        )
11579        .await
11580        .unwrap();
11581        // A second confirmed close of the now-missing path is a clean success,
11582        // not an error (a stale snapshot must not crash).
11583        let reply = svc
11584            .handle(
11585                "close",
11586                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11587            )
11588            .await
11589            .unwrap();
11590        assert_eq!(reply, json!({ "removed": true }));
11591    }
11592
11593    #[tokio::test]
11594    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11595        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
11596        // behind so the tree view keeps showing a `prunable` row. Closing it must
11597        // actually prune the admin metadata (via the registered window's repo), not
11598        // report a `pruned` no-op that leaves the row stuck.
11599        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11600        let svc = WorktreesService::new();
11601        // A live window on the main repo — the vantage point the stuck row is
11602        // enumerated from, and the one the prune locates the owner through.
11603        svc.handle(
11604            "register",
11605            register_payload("main-w", None, &main_root.display().to_string()),
11606        )
11607        .await
11608        .unwrap();
11609
11610        // Before: the orphaned linked worktree still shows alongside the main tree.
11611        let before = svc.handle("tree", Value::Null).await.unwrap();
11612        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11613        assert_eq!(
11614            worktrees_before, 2,
11615            "the orphaned row is present before close"
11616        );
11617
11618        let reply = svc
11619            .handle(
11620                "close",
11621                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11622            )
11623            .await
11624            .unwrap();
11625        assert_eq!(reply, json!({ "removed": true }));
11626
11627        // After: admin metadata pruned and the row is gone from the tree view.
11628        assert!(!admin.exists(), "the admin metadata must be pruned");
11629        let after = svc.handle("tree", Value::Null).await.unwrap();
11630        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11631        assert_eq!(worktrees_after, 1, "only the main working tree remains");
11632    }
11633
11634    #[tokio::test]
11635    async fn close_safety_check_detects_detached_head_unreachable_commits() {
11636        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11637        // In the worktree, commit onto a detached HEAD so the new commit is
11638        // reachable from no ref — it would be GC'd on removal.
11639        let wt_repo = Repository::open(&wt_path).unwrap();
11640        let parent_oid = wt_repo.head().unwrap().target().unwrap();
11641        let parent = wt_repo.find_commit(parent_oid).unwrap();
11642        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11643        wt_repo.set_head_detached(orphan).unwrap();
11644
11645        let svc = WorktreesService::new();
11646        let report = svc
11647            .handle("close", json!({ "path": wt_path, "remove": true }))
11648            .await
11649            .unwrap();
11650        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11651        assert!(
11652            risks
11653                .iter()
11654                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11655            "expected an unreachable-commits risk: {report}"
11656        );
11657    }
11658
11659    #[tokio::test]
11660    async fn close_self_close_removes_when_the_requester_owns_the_target() {
11661        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11662        let svc = WorktreesService::new();
11663        // The requesting window itself has the worktree open: it is the only
11664        // owning window, so there is nothing to wait on — remove and reply, and
11665        // the extension closes its own window on `ok`.
11666        svc.handle(
11667            "register",
11668            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11669        )
11670        .await
11671        .unwrap();
11672        let reply = svc
11673            .handle(
11674                "close",
11675                json!({
11676                    "path": wt_path,
11677                    "remove": true,
11678                    "confirmed": true,
11679                    "requester_key": "w1",
11680                }),
11681            )
11682            .await
11683            .unwrap();
11684        assert_eq!(reply, json!({ "removed": true }));
11685        assert!(!wt_path.exists());
11686    }
11687
11688    #[tokio::test]
11689    async fn close_safety_check_surfaces_the_owning_window() {
11690        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11691        let svc = WorktreesService::new();
11692        // A multi-root window owns the target: the report surfaces its key and
11693        // folder count so the extension can warn "all N folders will close".
11694        svc.handle(
11695            "register",
11696            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11697        )
11698        .await
11699        .unwrap();
11700        let report = svc
11701            .handle("close", json!({ "path": wt_path, "remove": true }))
11702            .await
11703            .unwrap();
11704        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11705        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11706        assert_eq!(
11707            report.get("window_folder_count").and_then(Value::as_u64),
11708            Some(2)
11709        );
11710    }
11711
11712    #[tokio::test]
11713    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11714        let svc = WorktreesService::new();
11715        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11716            .await
11717            .unwrap();
11718        // No directive → a plain `{ known: true }`, byte-identical to before.
11719        assert_eq!(
11720            svc.handle("heartbeat", json!({ "key": "w1" }))
11721                .await
11722                .unwrap(),
11723            json!({ "known": true })
11724        );
11725        // Marked → the next heartbeat carries `close: true`, exactly once.
11726        svc.registry.mark_close_pending("w1");
11727        assert_eq!(
11728            svc.handle("heartbeat", json!({ "key": "w1" }))
11729                .await
11730                .unwrap(),
11731            json!({ "known": true, "close": true })
11732        );
11733        assert_eq!(
11734            svc.handle("heartbeat", json!({ "key": "w1" }))
11735                .await
11736                .unwrap(),
11737            json!({ "known": true })
11738        );
11739    }
11740
11741    // --- Reload op (#1417) -------------------------------------------------
11742
11743    #[tokio::test]
11744    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11745        let svc = WorktreesService::new();
11746        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11747            .await
11748            .unwrap();
11749        // Nothing pending → `reload` is absent, so a companion that predates
11750        // #1417 sees a byte-identical reply.
11751        assert_eq!(
11752            svc.handle("heartbeat", json!({ "key": "w1" }))
11753                .await
11754                .unwrap(),
11755            json!({ "known": true })
11756        );
11757        // Marked → the next heartbeat carries `reload: true`, exactly once.
11758        svc.registry.mark_reload_pending("w1");
11759        assert_eq!(
11760            svc.handle("heartbeat", json!({ "key": "w1" }))
11761                .await
11762                .unwrap(),
11763            json!({ "known": true, "reload": true })
11764        );
11765        assert_eq!(
11766            svc.handle("heartbeat", json!({ "key": "w1" }))
11767                .await
11768                .unwrap(),
11769            json!({ "known": true })
11770        );
11771    }
11772
11773    #[tokio::test]
11774    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11775        let svc = WorktreesService::new();
11776        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11777            .await
11778            .unwrap();
11779        // Independent `if`s, not an `else`: both fields ride the same reply and
11780        // both are consumed, so neither directive can be stranded by the other.
11781        // The companion resolves the collision by checking `close` first.
11782        svc.registry.mark_close_pending("w1");
11783        svc.registry.mark_reload_pending("w1");
11784        assert_eq!(
11785            svc.handle("heartbeat", json!({ "key": "w1" }))
11786                .await
11787                .unwrap(),
11788            json!({ "known": true, "close": true, "reload": true })
11789        );
11790        assert_eq!(
11791            svc.handle("heartbeat", json!({ "key": "w1" }))
11792                .await
11793                .unwrap(),
11794            json!({ "known": true })
11795        );
11796    }
11797
11798    #[tokio::test]
11799    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11800        let svc = WorktreesService::new();
11801        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11802            .await
11803            .unwrap();
11804        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11805            .await
11806            .unwrap();
11807
11808        // A key with no live window is reported, never an error: a window
11809        // closing between the client listing and sending is routine.
11810        let reply = svc
11811            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11812            .await
11813            .unwrap();
11814        assert_eq!(
11815            reply,
11816            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11817        );
11818
11819        // Both live targets now have a directive waiting; the unknown one does
11820        // not (the daemon must not resurrect a key it never knew).
11821        assert!(svc.registry.take_reload_pending("w1"));
11822        assert!(svc.registry.take_reload_pending("w2"));
11823        assert!(!svc.registry.take_reload_pending("ghost"));
11824    }
11825
11826    #[tokio::test]
11827    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11828        let svc = WorktreesService::new();
11829        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11830            .await
11831            .unwrap();
11832
11833        // A client repeating a key asks for one reload, not two — `requested`
11834        // counts distinct targets so the client's summary cannot overstate.
11835        assert_eq!(
11836            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11837                .await
11838                .unwrap(),
11839            json!({ "requested": 1, "signalled": 1, "unknown": [] })
11840        );
11841
11842        // An empty batch is a no-op success, and a missing field is an empty
11843        // batch — the callers filter their targets before sending.
11844        assert_eq!(
11845            svc.handle("reload", json!({ "target_keys": [] }))
11846                .await
11847                .unwrap(),
11848            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11849        );
11850        assert_eq!(
11851            svc.handle("reload", json!({})).await.unwrap(),
11852            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11853        );
11854    }
11855
11856    #[tokio::test]
11857    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11858        let svc = WorktreesService::new();
11859        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11860            .await
11861            .unwrap();
11862        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11863            .await
11864            .unwrap();
11865
11866        // The end-to-end contract: `reload` marks, the target's own heartbeat
11867        // delivers. Unlike `close`, nothing waits — the op has already returned.
11868        svc.handle("reload", json!({ "target_keys": ["w2"] }))
11869            .await
11870            .unwrap();
11871        assert_eq!(
11872            svc.handle("heartbeat", json!({ "key": "w2" }))
11873                .await
11874                .unwrap(),
11875            json!({ "known": true, "reload": true })
11876        );
11877        // A window that was not a target is untouched.
11878        assert_eq!(
11879            svc.handle("heartbeat", json!({ "key": "w1" }))
11880                .await
11881                .unwrap(),
11882            json!({ "known": true })
11883        );
11884    }
11885
11886    #[tokio::test]
11887    async fn reload_op_treats_an_unregistered_window_as_unknown() {
11888        let svc = WorktreesService::new();
11889        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11890            .await
11891            .unwrap();
11892        svc.handle("unregister", json!({ "key": "w1" }))
11893            .await
11894            .unwrap();
11895        // `list()` reaps on read, so a window that has gone away cannot be
11896        // signalled — it is reported instead.
11897        assert_eq!(
11898            svc.handle("reload", json!({ "target_keys": ["w1"] }))
11899                .await
11900                .unwrap(),
11901            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11902        );
11903    }
11904
11905    #[tokio::test]
11906    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11907        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11908        let svc = Arc::new(WorktreesService::new());
11909        // A *different* window (not the requester) owns the target.
11910        svc.handle(
11911            "register",
11912            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11913        )
11914        .await
11915        .unwrap();
11916
11917        // Drive the destructive close concurrently: it marks w2 to close and
11918        // waits for it to unregister before removing.
11919        let svc2 = svc.clone();
11920        let path = wt_path.clone();
11921        let close = tokio::spawn(async move {
11922            svc2.handle(
11923                "close",
11924                json!({
11925                    "path": path,
11926                    "remove": true,
11927                    "confirmed": true,
11928                    "requester_key": "w1",
11929                }),
11930            )
11931            .await
11932        });
11933
11934        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
11935        // closes its window and unregisters. Poll until the directive appears.
11936        let mut saw_close = false;
11937        for _ in 0..200 {
11938            let hb = svc
11939                .handle("heartbeat", json!({ "key": "w2" }))
11940                .await
11941                .unwrap();
11942            if hb.get("close").and_then(Value::as_bool) == Some(true) {
11943                saw_close = true;
11944                svc.handle("unregister", json!({ "key": "w2" }))
11945                    .await
11946                    .unwrap();
11947                break;
11948            }
11949            tokio::time::sleep(Duration::from_millis(5)).await;
11950        }
11951        assert!(saw_close, "w2 should have been told to close");
11952
11953        // Once w2 has unregistered, the close op removes the worktree.
11954        let reply = close.await.unwrap().unwrap();
11955        assert_eq!(reply, json!({ "removed": true }));
11956        assert!(!wt_path.exists());
11957    }
11958
11959    #[tokio::test]
11960    async fn await_windows_closed_times_out_when_a_window_never_closes() {
11961        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11962        let svc = WorktreesService::new();
11963        svc.handle(
11964            "register",
11965            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11966        )
11967        .await
11968        .unwrap();
11969        // The owning window never unregisters: the wait gives up (with a short
11970        // timeout here) rather than block, and names the still-open window.
11971        let err = await_windows_closed(
11972            &svc.registry,
11973            &wt_path,
11974            Some("w1"),
11975            Duration::from_millis(150),
11976            Duration::from_millis(25),
11977        )
11978        .await
11979        .unwrap_err();
11980        assert!(
11981            err.to_string().contains("w2"),
11982            "error names the window: {err}"
11983        );
11984        // The requester itself is excluded, so a self-only owner returns at once.
11985        await_windows_closed(
11986            &svc.registry,
11987            &wt_path,
11988            Some("w2"),
11989            Duration::from_millis(150),
11990            Duration::from_millis(25),
11991        )
11992        .await
11993        .unwrap();
11994    }
11995
11996    #[tokio::test]
11997    async fn close_window_without_remove_replies_closed_and_never_deletes() {
11998        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11999        let svc = WorktreesService::new();
12000        // "Close Window" on the main tree: no git inspection, no removal.
12001        let reply = svc
12002            .handle("close", json!({ "path": main.path(), "remove": false }))
12003            .await
12004            .unwrap();
12005        assert_eq!(reply, json!({ "closed": true }));
12006        assert!(main.path().exists());
12007    }
12008
12009    #[tokio::test]
12010    async fn close_safety_check_flags_modified_tracked_files() {
12011        // A tracked file, checked out into the linked worktree, then modified —
12012        // its content is lost on removal, so it is a `dirty` risk (distinct from
12013        // the untracked case).
12014        let main_dir = tempfile::tempdir().unwrap();
12015        let repo = init_repo(main_dir.path());
12016        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12017        repo.set_head("refs/heads/trunk").unwrap();
12018        let wt_parent = tempfile::tempdir().unwrap();
12019        let wt_path = wt_parent.path().join("feature-wt");
12020        add_worktree(&repo, a, &wt_path, "feature");
12021        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12022
12023        let svc = WorktreesService::new();
12024        let report = svc
12025            .handle("close", json!({ "path": wt_path, "remove": true }))
12026            .await
12027            .unwrap();
12028        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12029        assert!(
12030            risks
12031                .iter()
12032                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12033            "expected a dirty risk: {report}"
12034        );
12035    }
12036
12037    #[tokio::test]
12038    async fn close_safety_check_flags_an_in_progress_operation() {
12039        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
12040        // non-Clean (interrupted merge) state — its progress is lost on removal.
12041        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12042        let wt_repo = Repository::open(&wt_path).unwrap();
12043        let head = wt_repo.head().unwrap().target().unwrap();
12044        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12045        assert_ne!(wt_repo.state(), RepositoryState::Clean);
12046
12047        let svc = WorktreesService::new();
12048        let report = svc
12049            .handle("close", json!({ "path": wt_path, "remove": true }))
12050            .await
12051            .unwrap();
12052        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12053        assert!(
12054            risks
12055                .iter()
12056                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12057            "expected an in-progress risk: {report}"
12058        );
12059    }
12060
12061    #[tokio::test]
12062    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12063        // A linked worktree on `feature`, which tracks `origin/feature` and is one
12064        // commit ahead. The unpushed commit is INFO (the branch — and thus the
12065        // commit — survives removal), never a blocking risk.
12066        let main_dir = tempfile::tempdir().unwrap();
12067        let repo = init_repo(main_dir.path());
12068        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12069        repo.set_head("refs/heads/trunk").unwrap();
12070        let a_commit = repo.find_commit(a).unwrap();
12071        repo.branch("feature", &a_commit, false).unwrap();
12072        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12073            .unwrap();
12074        // `feature` advances one commit past `origin/feature`.
12075        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12076        drop(a_commit);
12077        let mut cfg = repo.config().unwrap();
12078        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12079            .unwrap();
12080        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12081            .unwrap();
12082        cfg.set_str("branch.feature.remote", "origin").unwrap();
12083        cfg.set_str("branch.feature.merge", "refs/heads/feature")
12084            .unwrap();
12085        // A worktree on the existing `feature` branch (not created fresh, so it
12086        // keeps the ahead-of-upstream divergence).
12087        let wt_parent = tempfile::tempdir().unwrap();
12088        let wt_path = wt_parent.path().join("feature-wt");
12089        let reference = repo.find_reference("refs/heads/feature").unwrap();
12090        let mut opts = git2::WorktreeAddOptions::new();
12091        opts.reference(Some(&reference));
12092        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12093
12094        let svc = WorktreesService::new();
12095        let report = svc
12096            .handle("close", json!({ "path": wt_path, "remove": true }))
12097            .await
12098            .unwrap();
12099        // Unpushed commits appear as `info`, and the worktree is still cleanly
12100        // removable with no blocking risks.
12101        let info = report.get("info").and_then(Value::as_array).unwrap();
12102        assert!(
12103            info.iter()
12104                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12105            "expected an unpushed info note: {report}"
12106        );
12107        assert!(
12108            report
12109                .get("risks")
12110                .and_then(Value::as_array)
12111                .unwrap()
12112                .is_empty(),
12113            "unpushed commits alone must not block: {report}"
12114        );
12115        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12116    }
12117
12118    #[tokio::test]
12119    async fn close_safety_check_ignores_gitignored_files() {
12120        // With `.gitignore` committed, an ignored artifact is the only worktree
12121        // change — it must not count as untracked (it is regenerable), so the
12122        // worktree stays cleanly removable with no risks.
12123        let main_dir = tempfile::tempdir().unwrap();
12124        let repo = init_repo(main_dir.path());
12125        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12126        repo.set_head("refs/heads/trunk").unwrap();
12127        let wt_parent = tempfile::tempdir().unwrap();
12128        let wt_path = wt_parent.path().join("feature-wt");
12129        add_worktree(&repo, a, &wt_path, "feature");
12130        std::fs::create_dir(wt_path.join("build")).unwrap();
12131        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12132
12133        let svc = WorktreesService::new();
12134        let report = svc
12135            .handle("close", json!({ "path": wt_path, "remove": true }))
12136            .await
12137            .unwrap();
12138        assert!(
12139            report
12140                .get("risks")
12141                .and_then(Value::as_array)
12142                .unwrap()
12143                .is_empty(),
12144            "a gitignored file must not create a risk: {report}"
12145        );
12146        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12147    }
12148
12149    #[tokio::test]
12150    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12151        // The phase-1 check on a path that no longer exists reports it removable
12152        // with no risks (so the idempotent execute proceeds with no dialog).
12153        let svc = WorktreesService::new();
12154        let report = svc
12155            .handle(
12156                "close",
12157                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12158            )
12159            .await
12160            .unwrap();
12161        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12162        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12163        assert!(report
12164            .get("risks")
12165            .and_then(Value::as_array)
12166            .unwrap()
12167            .is_empty());
12168        let info = report.get("info").and_then(Value::as_array).unwrap();
12169        assert!(info
12170            .iter()
12171            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12172    }
12173
12174    #[tokio::test]
12175    async fn close_phase1_errors_on_a_non_git_worktree_path() {
12176        // An existing directory that is *not* a git worktree makes `git_safety`
12177        // fail; the error must propagate (rather than delete an unknown dir),
12178        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
12179        // audit line itself is unit-tested via `log_close_error`.
12180        let dir = tempfile::tempdir().unwrap();
12181        let svc = WorktreesService::new();
12182        let result = svc
12183            .handle("close", json!({ "path": dir.path(), "remove": true }))
12184            .await;
12185        assert!(
12186            result.is_err(),
12187            "a non-git-worktree target must error the safety check, got: {result:?}"
12188        );
12189    }
12190
12191    #[tokio::test]
12192    async fn close_refuses_a_locked_worktree() {
12193        // A locked worktree (git worktree lock) must be refused, not forced past
12194        // (failure mode #6), and left on disk.
12195        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12196        let main_repo = Repository::open(main.path()).unwrap();
12197        main_repo
12198            .find_worktree("feature")
12199            .unwrap()
12200            .lock(Some("under test"))
12201            .unwrap();
12202
12203        let svc = WorktreesService::new();
12204        let err = svc
12205            .handle(
12206                "close",
12207                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12208            )
12209            .await
12210            .unwrap_err();
12211        assert!(
12212            err.to_string().contains("locked"),
12213            "expected a locked error: {err}"
12214        );
12215        assert!(wt_path.exists(), "a locked worktree must not be removed");
12216    }
12217
12218    #[tokio::test]
12219    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12220        // A detached HEAD that still sits on a commit a branch points to loses
12221        // nothing on removal, so it must NOT produce an unreachable-commits risk
12222        // (the false-positive the reachability walk guards against).
12223        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12224        let wt_repo = Repository::open(&wt_path).unwrap();
12225        // The worktree is on `feature`; detach HEAD onto its current tip, which
12226        // the `feature` branch still references.
12227        let tip = wt_repo.head().unwrap().target().unwrap();
12228        wt_repo.set_head_detached(tip).unwrap();
12229        assert!(wt_repo.head_detached().unwrap());
12230
12231        let svc = WorktreesService::new();
12232        let report = svc
12233            .handle("close", json!({ "path": wt_path, "remove": true }))
12234            .await
12235            .unwrap();
12236        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12237        assert!(
12238            !risks
12239                .iter()
12240                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12241            "a detached HEAD reachable from a branch must not be flagged: {report}"
12242        );
12243    }
12244
12245    #[test]
12246    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12247        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12248        let main_repo = Repository::open(main.path()).unwrap();
12249        // The real linked worktree resolves to its registered name.
12250        assert_eq!(
12251            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12252            "feature"
12253        );
12254        // A path that is not one of this repo's worktrees is the defensive
12255        // "not registered" error (the guard behind removal).
12256        let err =
12257            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12258        assert!(
12259            err.to_string().contains("not registered"),
12260            "expected a not-registered error: {err}"
12261        );
12262    }
12263
12264    #[test]
12265    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12266        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
12267        // rather than sinking the whole safety check.
12268        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12269        let repo = Repository::open(&wt_path).unwrap();
12270        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12271        // Confirm the corruption actually breaks status enumeration, so the
12272        // count is exercising the error-degradation branch (not an empty repo).
12273        assert!(
12274            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12275            "a corrupt index should make statuses() fail"
12276        );
12277        assert_eq!(count_dirty_untracked(&repo), (0, 0));
12278    }
12279}