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 divergence;
34mod geometry;
35
36use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
37use std::path::{Path, PathBuf};
38use std::process::{Command, Stdio};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex, PoisonError};
41use std::time::{Duration, Instant};
42
43use anyhow::{anyhow, bail, Context, Result};
44use chrono::{DateTime, Utc};
45
46use crate::git::remote::RemoteInfo;
47use crate::git::worktree_batch::Selection;
48use crate::git::worktree_push;
49use crate::git::worktree_rebase;
50use crate::github_rate_limit::{
51    resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
52};
53use crate::pr_status::{
54    EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
55};
56use async_trait::async_trait;
57use divergence::{AheadBehindCoordinator, Divergence};
58use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
59use serde::{Deserialize, Serialize};
60use serde_json::{json, Value};
61use tokio::sync::watch;
62use tokio::sync::Mutex as AsyncMutex;
63use tokio::task::JoinHandle;
64use tokio_util::sync::CancellationToken;
65
66use crate::daemon::service::{
67    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
68};
69use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
70
71/// The worktrees service name (the control-socket routing key).
72pub const SERVICE_NAME: &str = "worktrees";
73
74/// The tray submenu title.
75const SUBMENU_TITLE: &str = "Worktrees";
76
77/// Environment override for the VS Code launcher used by the "focus" tray
78/// action, for when the daemon runs under launchd with a minimal `PATH`.
79const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
80
81/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
82/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
83const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
84
85/// Default cadence at which the background task recomputes the tray menu snapshot
86/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
87/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
88/// the GUI thread (which would peg a core and stall shutdown — the #1186
89/// regression); the interval only governs how stale that cached branch/sync state
90/// may be when the menu is opened.
91///
92/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
93/// walk that the subscription-stream coalescing (#1303) never touched — it was
94/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
95/// menu open-latency unchanged (the cache still serves instantly).
96const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
97
98/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
99/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
100fn menu_refresh_interval() -> Duration {
101    crate::daemon::server::duration_secs_from_env(
102        ENV_MENU_REFRESH_INTERVAL,
103        DEFAULT_MENU_REFRESH_INTERVAL,
104    )
105}
106
107/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
108/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
109/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
110const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
111
112/// Default cadence for the PR badge poller while CI is in flight (#1337).
113///
114/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
115/// watching a run — which is exactly this situation. It is affordable because the
116/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
117/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
118/// while something is actually pending.
119const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
120
121/// The ceiling the poller backs off to once every badge is terminal.
122///
123/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
124/// The 30-minute figure is the cross-tool consensus for background PR polling
125/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
126/// defaults). The backoff exists for battery and wakeups rather than budget — at
127/// 1 point per poll the budget never binds.
128const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
129
130/// How long after fresh work (a push, or an added target) the poller holds its
131/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
132/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
133/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
134/// is watched more cheaply. See [`next_pr_poll_delay`].
135const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
136
137/// The ceiling the poller escalates to *while still pending* once
138/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
139/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
140/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
141/// at ~50 calls instead of ~120.
142const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
143
144/// The cadence floor the poller is held to while the shared GitHub budget is
145/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
146/// Five minutes makes the daemon's contribution to an already-strained budget
147/// negligible while still recovering promptly once the window resets — a pause in
148/// all but name. See [`budget_throttled_delay`].
149const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
150
151/// Environment override for [`pr_debounce_interval`] — the settle window the PR
152/// poller waits after the change-notify fires before snapshotting (whole seconds;
153/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
154const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
155
156/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
157///
158/// A VS Code restart unregisters then re-registers its windows one-by-one over
159/// several seconds, each bump waking the poller; a daemon restart re-registers the
160/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
161/// storm into **one** fetch on the final watch set instead of one per window.
162const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
163
164/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
165/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
166fn pr_poll_interval() -> Duration {
167    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
168}
169
170/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
171/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
172fn pr_debounce_interval() -> Duration {
173    crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
174}
175
176/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
177/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
178/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
179const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
180
181/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
182/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
183/// the extension's transient badge fallback — from the daemon means N windows now
184/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
185const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
186
187/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
188/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
189/// `PullRequest` shape unchanged.
190const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
191
192/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
193/// (parity with the extension's `PR_LIST_LIMIT`).
194const OPEN_PR_LIST_LIMIT: &str = "100";
195
196/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
197/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
198fn open_pr_ttl() -> Duration {
199    crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
200}
201
202/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
203/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
204/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
205const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
206
207/// Default cadence for the GitHub rate-limit monitor (#1375).
208///
209/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
210/// spends nothing against any budget — so unlike the PR poller this cadence has no
211/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
212/// keeps `daemon status` current enough to catch a slow drain.
213const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
214
215/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
216/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
217fn rate_limit_poll_interval() -> Duration {
218    crate::daemon::server::duration_secs_from_env(
219        ENV_RATE_LIMIT_POLL_INTERVAL,
220        DEFAULT_RATE_LIMIT_POLL_INTERVAL,
221    )
222}
223
224/// A running background menu-refresh task and the token that stops it.
225struct RefreshTask {
226    /// Cancelled by `shutdown` to end the refresh loop.
227    token: CancellationToken,
228    /// The spawned loop, awaited on shutdown so it fully unwinds.
229    handle: JoinHandle<()>,
230}
231
232/// Whether this tick should spend a `gh` call.
233///
234/// The poller wakes far more often than it fetches — waking is a cached snapshot
235/// read, fetching is a subprocess and a network round trip. Two things justify the
236/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
237/// a push, invisible to the change-notify, so only looking finds it), or the
238/// **backoff elapsed** and it is simply time to look again.
239///
240/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
241/// worktree going away, a lease lapsing, or a TTL reap can never change any
242/// **surviving** badge, so `grew` is false and the poll skips — see
243/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
244///
245/// Pure so the policy is testable directly: from outside, the only evidence of it
246/// is *when* a subprocess runs, which a test cannot pin down without either flaking
247/// or passing for the wrong reason.
248fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
249    grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
250}
251
252/// Whether `next` holds a watch the poller has not already resolved for its
253/// current upstream — an **addition** (a new (repo, branch) target) or an
254/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
255/// reports). Either warrants asking GitHub *now*.
256///
257/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
258/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
259/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
260/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
261/// exactly the cached verdict, and the badge stays correctly stale through
262/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
263/// network call (#1389, fix 3).
264///
265/// Pure so the fetch trigger is testable without driving a live subprocess.
266fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
267    next.iter().any(|w| !prev.contains(w))
268}
269
270/// The next PR-poll delay.
271///
272/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
273///   — nothing is expected to change, so this is a slow liveness heartbeat. A failed
274///   poll passes `pending: false`, so a persistent failure backs off here rather
275///   than being retried hard.
276/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
277///   [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
278///   `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
279///   run used to pin `base` for its whole duration (360 calls/hour); escalating
280///   *within* pending caps that while a verdict arriving a cadence-tick late stays
281///   invisible in the tray. `since_moved` is time since fresh work was last seen (a
282///   push or an added target), or `None` when nothing has moved yet — treated as
283///   past the fast window so a stale-from-boot pending state does not pin `base`.
284///
285/// A pure function rather than copies inline, because the cadence is only
286/// observable from outside as timing, which a test cannot assert without flaking.
287fn next_pr_poll_delay(
288    current: Duration,
289    base: Duration,
290    pending: bool,
291    since_moved: Option<Duration>,
292) -> Duration {
293    if !pending {
294        return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
295    }
296    match since_moved {
297        // Fresh work: watch it closely at `base` while it is likely to resolve.
298        Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
299        // Still pending well after the move (a long CI run, or a zombie suite):
300        // escalate so the cadence stops burning, bounded below the terminal ceiling.
301        _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
302    }
303}
304
305/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
306///
307/// The daemon is the **single** `gh` choke point for every open window, so this is
308/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
309/// any tracked resource is at/over
310/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
311/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
312/// the budget the whole machine shares — structurally, not just by convention. Below
313/// the threshold (or with no reading yet) the delay is returned unchanged.
314///
315/// Pure so the throttle is testable without a live rate-limit poll.
316fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
317    if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
318        delay.max(BUDGET_THROTTLE_INTERVAL)
319    } else {
320        delay
321    }
322}
323
324/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
325/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
326/// threshold upward since the previous reading (or is already over on the first
327/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
328/// per crossing rather than every poll while usage stays high.
329///
330/// Pure so the policy is testable without driving a live poll.
331fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
332    let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
333    // Per-resource so a *different* resource crossing (while another recovers) is
334    // still caught — `graphql` dropping below while `core` climbs over would look
335    // unchanged to a whole-snapshot `over_warn` comparison.
336    [
337        (prev.and_then(|p| p.graphql), next.graphql),
338        (prev.and_then(|p| p.core), next.core),
339        (prev.and_then(|p| p.search), next.search),
340    ]
341    .into_iter()
342    .any(|(before, after)| over(after) && !over(before))
343}
344
345/// A running background PR-badge poll task and the token that stops it.
346struct PollerTask {
347    /// Cancelled by `shutdown` to end the poll loop.
348    token: CancellationToken,
349    /// The spawned loop, awaited on shutdown so it fully unwinds.
350    handle: JoinHandle<()>,
351}
352
353/// One thing the PR poller watches: a badge target and the commit its upstream
354/// points at.
355///
356/// The upstream OID is what makes a **push** observable to the poller. A window
357/// opening bumps the registry's change-notify, but nothing notifies the daemon
358/// when you push — so the poller compares this against the previous tick's and
359/// treats an added target or a moved upstream as "go and ask now" (see
360/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
361/// very thing that starts the CI run a badge reports, so the upstream must be here.
362///
363/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
364/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
365/// call, and the badge stays correctly stale through
366/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
367/// comparison, no network — until the branch is actually pushed. Equality is thus
368/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
369#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
370struct PrWatch {
371    /// The (repo, branch) to resolve a badge for.
372    target: PrTarget,
373    /// That branch's upstream tip, or `None` when it tracks no upstream.
374    upstream_sha: Option<String>,
375}
376
377/// Extracts what the poller watches — the badge targets and their local heads and
378/// upstream tips — from a `tree` snapshot.
379///
380/// Reading them back off the snapshot — rather than walking git again — means the
381/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
382/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
383/// out of its way to remove. Only GitHub repos with a branch contribute; the result
384/// is sorted and deduped so N worktrees of one repo on one branch ask once.
385fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
386    let mut out = Vec::new();
387    for repo in snapshot
388        .get("repos")
389        .and_then(Value::as_array)
390        .into_iter()
391        .flatten()
392    {
393        // The zero-`gh` guarantee (#1376): a repo the user has not enabled
394        // contributes no watch, so the poll's `gh api graphql` never mentions it.
395        // The snapshot this reads is already `stamp_polling`-stamped, so this one
396        // check is the single filter point — default-off means an absent flag skips.
397        if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
398            continue;
399        }
400        let Some(github) = repo.get("github") else {
401            continue;
402        };
403        let (Some(owner), Some(name)) = (
404            github.get("owner").and_then(Value::as_str),
405            github.get("name").and_then(Value::as_str),
406        ) else {
407            continue;
408        };
409        for wt in repo
410            .get("worktrees")
411            .and_then(Value::as_array)
412            .into_iter()
413            .flatten()
414        {
415            if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
416                out.push(PrWatch {
417                    upstream_sha: wt
418                        .get("upstream_sha")
419                        .and_then(Value::as_str)
420                        .map(str::to_string),
421                    target: PrTarget {
422                        owner: owner.to_string(),
423                        name: name.to_string(),
424                        branch: branch.to_string(),
425                    },
426                });
427            }
428        }
429    }
430    out.sort();
431    out.dedup();
432    out
433}
434
435/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
436/// without the heads.
437#[cfg(test)]
438fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
439    pr_watch_from_snapshot(snapshot)
440        .into_iter()
441        .map(|w| w.target)
442        .collect()
443}
444
445/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
446pub struct WorktreesService {
447    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
448    /// the background menu-refresh task can read it off the main thread.
449    registry: Arc<WorktreesRegistry>,
450    /// The most recent tray menu snapshot, recomputed off the main thread by
451    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
452    /// of this so it never blocks on git enrichment. `None` until the first
453    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
454    /// which case `menu()` falls back to a one-off inline compute.
455    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
456    /// The background refresh task, once started (`None` in tests / no runtime).
457    refresh: Mutex<Option<RefreshTask>>,
458    /// PR badges resolved by the background poller and read by the tree snapshot
459    /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
460    /// share the one cache. Empty until the first poll lands — and always empty
461    /// when no poller runs (unit tests), in which case the tree simply carries no
462    /// `pr` field, exactly as a pre-#1337 daemon did.
463    pr_cache: Arc<PrStatusCache>,
464    /// The background PR-badge poll task, once started (`None` in tests / no
465    /// runtime).
466    poller: Mutex<Option<PollerTask>>,
467    /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
468    /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
469    /// poll task, the tray refresh, and the daemon's registry share the one cache.
470    /// Empty until the first poll lands; the daemon hands a clone to the registry
471    /// so `status` can report machine-wide GitHub budget usage.
472    ///
473    /// [`rate-limit poller`]: Self::start_rate_limit_poller
474    rate_limit_cache: Arc<RateLimitCache>,
475    /// The background rate-limit poll task, once started (`None` in tests / no
476    /// runtime).
477    rate_limit_poller: Mutex<Option<PollerTask>>,
478    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
479    /// through, so N open windows perform **one** `build_tree` per tick instead
480    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
481    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
482    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
483    tree_cache: Arc<TreeSnapshotCache>,
484    /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
485    ///
486    /// The extension fans a multi-select delete out into one `close` op per
487    /// target, so two executes can reach the prune at once. Their heartbeat waits
488    /// overlap freely — that is the point — but the prunes themselves should not:
489    /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
490    /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
491    /// read a directory a sibling is midway through removing from, and `git2`
492    /// promises nothing about that. Precautionary rather than a fix for an
493    /// observed corruption — the window is narrow enough that it has not been
494    /// reproduced — but serializing costs nothing measurable (the prune is a
495    /// directory delete; the wait it follows is seconds) and keeps the fan-out
496    /// safe at the source rather than relying on every caller to stay sequential.
497    ///
498    /// A `tokio` mutex rather than a `std` one: it is held across the
499    /// `spawn_blocking` join, which is an `.await`.
500    prune_lock: tokio::sync::Mutex<()>,
501    /// Shares and bounds the `ahead-behind` op's git work across concurrent
502    /// requests (#2111): N windows asking about the same worktree cost one
503    /// computation, and only a few run at once. See [`divergence`].
504    ahead_behind: AheadBehindCoordinator,
505    /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
506    /// choice survives a daemon restart. `None` disables persistence entirely —
507    /// the default from [`new`](Self::new), which keeps the bare service cheap
508    /// and I/O-free for unit tests; the daemon wires it via
509    /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
510    /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
511    /// briefly and never held across an `.await`.
512    polling_prefs_path: Mutex<Option<PathBuf>>,
513    /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
514    /// survive a daemon restart and the poller can skip its immediate re-poll when
515    /// they are still fresh. `None` disables persistence — the default from
516    /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
517    /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
518    /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
519    pr_cache_path: Mutex<Option<PathBuf>>,
520    /// The warm-start state restored from the persisted cache (#1389, fix 4),
521    /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
522    /// spawns. `None` on a cold start (no file, or persistence disabled), in which
523    /// case the poller does its normal first fetch.
524    pr_warm_start: Mutex<Option<PrWarmStart>>,
525    /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
526    /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
527    /// dedupe to one counted `gh` per repo instead of one per window. Behind an
528    /// `Arc` for parity with the other caches.
529    open_pr_cache: Arc<OpenPrCache>,
530    /// Where the windows the **last** `reposition` moved were sitting beforehand,
531    /// so `reposition-undo` can put them back (#1407).
532    ///
533    /// Exactly one level of undo, deliberately: the affordance exists because
534    /// repositioning is otherwise irreversible (the previous layout is simply
535    /// gone), and "undo the thing I just did" is the whole of what that needs. A
536    /// deeper stack would raise questions — what does undoing an older batch mean
537    /// once a newer one has moved the same window? — that a one-level store cannot
538    /// pose. Each successful `reposition` replaces it; each `reposition-undo`
539    /// consumes it, so an undo cannot be replayed.
540    ///
541    /// In-memory only, like every other piece of registry state: a daemon restart
542    /// drops it, and the user is simply left with the layout they have. Behind its
543    /// **own** `std::Mutex`, taken independently of the registry's (neither nests)
544    /// and never held across an `.await`.
545    reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
546    /// Serializes the `rebase` op's phase-2 execute across concurrent requests
547    /// (#1415) — the [`prune_lock`](Self::prune_lock) precedent, one op over.
548    ///
549    /// Within a batch the engine already rebases sequentially, on purpose: linked
550    /// worktrees share one object database, and `git rebase` writes refs and
551    /// packs into it. Two *concurrent requests* would defeat that, so the lock
552    /// restores it globally. It also means a second click cannot start a rebase of
553    /// a worktree the first is still mid-way through — the `operation`-in-progress
554    /// classifier only sees state that is already on disk.
555    ///
556    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
557    rebase_lock: tokio::sync::Mutex<()>,
558    /// Serializes the `push` op's phase-2 execute across concurrent requests
559    /// (#1443) — the [`rebase_lock`](Self::rebase_lock) twin.
560    ///
561    /// A successful push writes `refs/remotes/<remote>/<branch>` into the object
562    /// database and ref store that every linked worktree of the repository shares,
563    /// so concurrent batches would race on it. Taking the lock **before** the
564    /// re-plan (not merely around the execute) is what keeps the classification the
565    /// engine acts on from being invalidated by another batch's push mid-flight —
566    /// which for a lease is not benign: a plan taken outside the lock could still
567    /// say `would-force` against a tracking ref the other run has since advanced.
568    ///
569    /// Deliberately **separate** from `rebase_lock` rather than shared: a rebase and
570    /// a push touch different refs (`refs/heads/*` vs `refs/remotes/*`) and there is
571    /// no reason a push should wait behind an unrelated repository's rebase.
572    ///
573    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
574    push_lock: tokio::sync::Mutex<()>,
575}
576
577impl WorktreesService {
578    /// Creates the service with an empty registry. Cheap — no I/O and no task;
579    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
580    /// off-thread menu caching, while tests use the bare service (menu computed
581    /// inline on demand).
582    #[must_use]
583    pub fn new() -> Self {
584        let registry = Arc::new(WorktreesRegistry::new());
585        let pr_cache = Arc::new(PrStatusCache::new());
586        Self {
587            registry: registry.clone(),
588            menu_cache: Arc::new(Mutex::new(None)),
589            refresh: Mutex::new(None),
590            pr_cache: pr_cache.clone(),
591            poller: Mutex::new(None),
592            rate_limit_cache: Arc::new(RateLimitCache::new()),
593            rate_limit_poller: Mutex::new(None),
594            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
595            prune_lock: tokio::sync::Mutex::new(()),
596            ahead_behind: AheadBehindCoordinator::new(),
597            polling_prefs_path: Mutex::new(None),
598            pr_cache_path: Mutex::new(None),
599            pr_warm_start: Mutex::new(None),
600            open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
601            reposition_undo: Mutex::new(Vec::new()),
602            rebase_lock: tokio::sync::Mutex::new(()),
603            push_lock: tokio::sync::Mutex::new(()),
604        }
605    }
606
607    /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
608    /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
609    /// back to it (#1376). Called once by the daemon at startup, before any window
610    /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
611    /// bump. Best-effort throughout: a missing file is the first-run default (no
612    /// repos enabled), and a corrupt/unreadable one is logged and treated as
613    /// empty rather than wedging the service — the user simply re-enables. The
614    /// path is stored regardless, so the next change rewrites a clean file.
615    pub fn load_polling_prefs(&self, path: PathBuf) {
616        match std::fs::read(&path) {
617            Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
618                Ok(prefs) => self
619                    .registry
620                    .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
621                Err(err) => tracing::warn!(
622                    "ignoring unreadable worktrees polling prefs at {}: {err:#}",
623                    path.display()
624                ),
625            },
626            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
627            Err(err) => tracing::warn!(
628                "could not read worktrees polling prefs at {}: {err:#}",
629                path.display()
630            ),
631        }
632        *self
633            .polling_prefs_path
634            .lock()
635            .unwrap_or_else(PoisonError::into_inner) = Some(path);
636    }
637
638    /// Writes the current enable set to the `0600` prefs file, if persistence is
639    /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
640    /// Best-effort: a write failure is logged at WARN and swallowed, since the
641    /// in-memory set is authoritative for the running daemon — the user's toggle
642    /// still took effect, it just would not survive a restart. A no-op (returns
643    /// early) in unit tests, which never configure a path.
644    fn persist_polling_prefs(&self) {
645        let Some(path) = self
646            .polling_prefs_path
647            .lock()
648            .unwrap_or_else(PoisonError::into_inner)
649            .clone()
650        else {
651            return;
652        };
653        let prefs = PollingPrefs {
654            enabled: self
655                .registry
656                .polling_snapshot()
657                .into_iter()
658                .map(|(repo, expires_at)| PollingLease { repo, expires_at })
659                .collect(),
660        };
661        if let Err(err) = write_polling_prefs(&path, &prefs) {
662            tracing::warn!(
663                "could not persist worktrees polling prefs to {}: {err:#}",
664                path.display()
665            );
666        }
667    }
668
669    /// Seeds the resolved PR-badge cache from the persisted `0600` file and
670    /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
671    /// once by the daemon at startup, before any window subscribes and before the
672    /// poller spawns, so restored badges render on the first tree snapshot and the
673    /// poller can skip its immediate re-poll for verdicts still fresh.
674    ///
675    /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
676    /// contract): a missing file is the cold-start default, and a corrupt/unreadable
677    /// one is logged and treated as empty — the poller simply re-resolves. The path
678    /// is stored regardless, so the next poll rewrites a clean file. Restores both
679    /// the badges (into [`pr_cache`](Self::pr_cache)) and the
680    /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
681    pub fn load_pr_cache(&self, path: PathBuf) {
682        match std::fs::read(&path) {
683            Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
684                Ok(prefs) => {
685                    self.pr_cache.seed(
686                        prefs
687                            .entries
688                            .into_iter()
689                            .map(|e| (e.target, e.resolution.into_resolution())),
690                    );
691                    // A warm start needs both a watch set to compare against and a
692                    // poll time to age it; without `polled_at` the file is too old a
693                    // shape to trust, so treat it as a cold start (badges still
694                    // render, the poller just re-polls immediately).
695                    if let Some(polled_at) = prefs.polled_at {
696                        let watched = prefs
697                            .watched
698                            .into_iter()
699                            .map(|w| PrWatch {
700                                target: w.target,
701                                upstream_sha: w.upstream_sha,
702                            })
703                            .collect();
704                        *self
705                            .pr_warm_start
706                            .lock()
707                            .unwrap_or_else(PoisonError::into_inner) =
708                            Some(PrWarmStart { watched, polled_at });
709                    }
710                }
711                // Bind the path so it is formatted whenever the branch runs — not
712                // only when a WARN subscriber is installed — so coverage sees it
713                // (the `let summary = …` pattern the rate-limit warn uses).
714                Err(err) => {
715                    let at = path.display();
716                    tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
717                }
718            },
719            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
720            Err(err) => {
721                let at = path.display();
722                tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
723            }
724        }
725        *self
726            .pr_cache_path
727            .lock()
728            .unwrap_or_else(PoisonError::into_inner) = Some(path);
729    }
730
731    /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
732    /// served from the shared TTL cache when fresh, else **one** counted `gh pr
733    /// list`. The `gh` runs on a blocking thread (never an async worker), routed
734    /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
735    /// point so the call is still counted exactly once — the constraint the whole
736    /// of #1389 preserves. The result is forwarded to the extension verbatim.
737    async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
738        // The `gh` binary is resolved once here (the env read is process-stable),
739        // then handed to the seam below — the poller's "bin as a param" pattern, so
740        // a test injects a stub without mutating the process environment (#1030).
741        self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
742            .await
743    }
744
745    /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
746    /// the cache against a stub without touching the environment.
747    async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
748        let key = format!("{owner}/{name}");
749        if let Some(prs) = self.open_pr_cache.fresh(&key) {
750            return Ok(prs);
751        }
752        let slug = key.clone();
753        let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
754            .await
755            .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
756        self.open_pr_cache.store(key, prs.clone());
757        Ok(prs)
758    }
759
760    /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
761    /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
762    /// for the built-in `status` op to read.
763    #[must_use]
764    pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
765        self.rate_limit_cache.clone()
766    }
767
768    /// Starts the background task that recomputes the tray menu snapshot every
769    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
770    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
771    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
772    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
773    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
774    /// keep computing the menu inline.
775    pub fn start_menu_refresh(&self) {
776        if tokio::runtime::Handle::try_current().is_err() {
777            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
778            return;
779        }
780        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
781        if guard.is_some() {
782            return;
783        }
784        let token = CancellationToken::new();
785        let loop_token = token.clone();
786        let registry = self.registry.clone();
787        let cache = self.menu_cache.clone();
788        let rate_limit_cache = self.rate_limit_cache.clone();
789        // Resolved once at spawn: the interval is process-stable env config, and
790        // re-reading it every loop would be wasted work.
791        let interval = menu_refresh_interval();
792        let handle = tokio::spawn(async move {
793            loop {
794                // Snapshot the registry (a cheap lock), then build the menu —
795                // which opens repos and parses git config — on a blocking thread,
796                // never on this async worker or the tray's main thread.
797                let entries = registry.list();
798                // The rate-limit reading (a cheap lock, `Copy`) prepends a status
799                // line; read it here and hand it to the blocking build (#1375).
800                let rate_limit = rate_limit_cache.get();
801                if let Ok(items) = tokio::task::spawn_blocking(move || {
802                    menu_items_for(&entries, rate_limit.as_ref())
803                })
804                .await
805                {
806                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
807                }
808                tokio::select! {
809                    () = loop_token.cancelled() => break,
810                    () = tokio::time::sleep(interval) => {}
811                }
812            }
813        });
814        *guard = Some(RefreshTask { token, handle });
815    }
816
817    /// Starts the background task that keeps PR check badges fresh (#1337).
818    ///
819    /// This is the half of the badge nothing else can do. Badges used to be
820    /// resolved extension-side on repo-expand, so they were recomputed only when a
821    /// repo node's children were rebuilt — and the streamed snapshot carries
822    /// worktree topology, not CI. While CI ran and no window opened or closed,
823    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
824    ///
825    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
826    /// (cost 1, independent of repo/worktree/window count), writes the cache the
827    /// tree snapshot reads, and bumps the registry's change-notify **only when a
828    /// verdict actually moved** — so the server's diff pushes to every open window
829    /// exactly when CI state changes, and never otherwise.
830    ///
831    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
832    /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
833    /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
834    /// terminal; it polls nothing at all while no window is registered.
835    ///
836    /// It spends a `gh` call only when the watch set **grows** — a target added or
837    /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
838    /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
839    /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
840    /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
841    /// 4), and the cadence is capped when the shared GitHub budget is strained
842    /// (#1389 fix 6).
843    ///
844    /// Idempotent, and a no-op outside a tokio runtime (mirroring
845    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
846    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
847    pub fn start_pr_poller(&self) {
848        // Resolved once at spawn: process-stable env config (the menu-refresh
849        // precedent), never re-read per poll.
850        self.start_pr_poller_with(
851            pr_poll_interval(),
852            pr_debounce_interval(),
853            crate::pr_status::resolve_gh_binary(),
854        );
855    }
856
857    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
858    /// debounce settle window, and `gh` binary, so tests drive the loop at
859    /// millisecond speed against a stub **without mutating the process
860    /// environment** — one global env var cannot serve two parallel tests pointing
861    /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
862    /// Snowflake heartbeat's "interval via config, not env" rule.
863    ///
864    /// Reads the rate-limit cache, the persistence path, and the warm-start state
865    /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
866    /// the original two-cadence seam.
867    fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
868        if tokio::runtime::Handle::try_current().is_err() {
869            tracing::debug!("no tokio runtime; worktrees PR poller not started");
870            return;
871        }
872        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
873        if guard.is_some() {
874            return;
875        }
876        let token = CancellationToken::new();
877        let loop_token = token.clone();
878        let registry = self.registry.clone();
879        let tree_cache = self.tree_cache.clone();
880        let pr_cache = self.pr_cache.clone();
881        // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
882        // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
883        // the loop once and must not be reused by a later restart of the poller.
884        let rate_limit_cache = self.rate_limit_cache.clone();
885        let pr_cache_path = self
886            .pr_cache_path
887            .lock()
888            .unwrap_or_else(PoisonError::into_inner)
889            .clone();
890        let warm_start = self
891            .pr_warm_start
892            .lock()
893            .unwrap_or_else(PoisonError::into_inner)
894            .take();
895        // Captured here, before the task's first sleep, so a window that registers
896        // while the loop is starting still wakes it rather than being missed.
897        let mut changes = self.registry.subscribe_changes();
898        let handle = tokio::spawn(async move {
899            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
900            // of the coalescing snapshot cache, no subprocess, no network. It only
901            // *asks GitHub* when there is reason to: the watch set grew (a target
902            // added, or an upstream pushed), or the backoff has elapsed.
903            //
904            // They have to be separate because the two things that should trigger a
905            // fetch arrive by different routes. A window opening bumps the registry's
906            // change-notify, but **a push does not** — nothing in the daemon is
907            // notified when you `git push`. The only way to notice is to look, so the
908            // loop looks often and cheaply, and pays only when something grew.
909            let mut backoff = base;
910            // Warm start (#1389, fix 4): resume what the previous daemon last
911            // resolved and when, so a restart within the backoff window skips the
912            // immediate re-poll for verdicts already restored into `pr_cache`.
913            // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
914            // reboot (monotonic epoch reset) or a future timestamp collapses to
915            // "never polled", which just re-polls — the safe direction.
916            let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
917                match warm_start {
918                    Some(ws) => {
919                        let elapsed = (Utc::now() - ws.polled_at)
920                            .to_std()
921                            .unwrap_or(Duration::ZERO);
922                        (Some(ws.watched), Instant::now().checked_sub(elapsed))
923                    }
924                    None => (None, None),
925                };
926            // When fresh work (a push or an added target) was last seen, so the
927            // pending cadence can escalate once it goes quiet (#1389, fix 5).
928            let mut moved_at: Option<Instant> = None;
929            'poll: loop {
930                // Wait first: at startup no window has registered yet, and the
931                // first snapshot would be empty anyway.
932                tokio::select! {
933                    () = loop_token.cancelled() => break,
934                    () = tokio::time::sleep(base) => {}
935                    // A window opened or closed — look now rather than at the next
936                    // tick, but debounce first.
937                    result = changes.changed() => {
938                        // Unreachable today: this task owns an `Arc` of the registry
939                        // that holds the sender, so it cannot be dropped while we are
940                        // here. Kept anyway because the alternative is worse — a
941                        // closed channel makes `changed()` return `Ready` forever, so
942                        // ignoring the error would spin this loop at full speed,
943                        // re-snapshotting and re-running `gh` every iteration.
944                        if result.is_err() {
945                            break;
946                        }
947                        // Debounce (#1389, fix 2): a VS Code restart unregisters then
948                        // re-registers its windows one-by-one over several seconds,
949                        // each bump waking us; a daemon restart re-registers the same
950                        // way. Wait for `debounce` of quiet before snapshotting so the
951                        // whole storm collapses to **one** fetch on the final watch
952                        // set. Bounded by an overall deadline so a steady drip of
953                        // changes cannot postpone the poll forever.
954                        let overall_deadline = Instant::now() + debounce.saturating_mul(4);
955                        loop {
956                            tokio::select! {
957                                () = loop_token.cancelled() => break 'poll,
958                                () = tokio::time::sleep(debounce) => break,
959                                r = changes.changed() => {
960                                    if r.is_err() {
961                                        break 'poll;
962                                    }
963                                    if Instant::now() >= overall_deadline {
964                                        break;
965                                    }
966                                }
967                            }
968                        }
969                    }
970                }
971                // Off the coalescing snapshot cache, so this reuses the tick's
972                // `build_tree` rather than walking git a second time.
973                let snapshot = tree_cache.snapshot().await;
974                let watch = pr_watch_from_snapshot(&snapshot);
975                if watch.is_empty() {
976                    // No windows, or nothing on GitHub: ask nothing, and forget any
977                    // backoff so the next tree starts fresh. But **keep** `watched`
978                    // (#1389, fix 4): a VS Code restart momentarily empties the watch
979                    // mid-storm, and nulling it here would make the re-registered set
980                    // look brand-new and re-fetch. A genuinely gone tree simply has
981                    // nothing to compare against on the next non-empty tick.
982                    backoff = base;
983                    last_poll = None;
984                    moved_at = None;
985                    continue;
986                }
987                // Did the watched set **grow** (an addition, or an upstream pushed)?
988                // A pure removal is not a reason to fetch (#1389, fix 1); a local
989                // commit is not either (#1389, fix 3, `PrWatch` carries no head).
990                let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
991                // Prune verdicts for targets that vanished, so a closed worktree's
992                // badge does not linger in the cache (#1389, fix 1) — local, no
993                // network, and correct whether or not this tick goes on to fetch.
994                let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
995                pr_cache.retain_targets(&keep);
996                // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
997                // choke point, so throttling here is the one place a machine-wide cap
998                // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
999                // immediate `grew`, so no runaway in this class can drain the shared
1000                // budget — structurally, not by convention.
1001                let rate_limit = rate_limit_cache.get();
1002                let over_budget = rate_limit.is_some_and(|s| s.over_warn());
1003                let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
1004                let trigger = grew && !over_budget;
1005                if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1006                    // Not fetching this tick. Advance `watched` only for a pure shrink
1007                    // or a quiet identical tick — an addition/push (`grew`) must stay
1008                    // unresolved so it is still fetched once the cadence or budget
1009                    // allows, rather than being silently consumed here.
1010                    if !grew {
1011                        watched = Some(watch);
1012                    }
1013                    continue;
1014                }
1015                if grew {
1016                    // Fresh work: watch it closely and restart the escalation clock
1017                    // rather than serving out a backoff earned while it was quiet.
1018                    backoff = base;
1019                    moved_at = Some(Instant::now());
1020                }
1021                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1022                // `gh` is a blocking subprocess: never on an async worker. A join
1023                // failure (the task panicked, or the runtime is going down) folds
1024                // into the same error channel as a `gh` failure — both mean "no
1025                // badges this round", and neither deserves its own handling.
1026                let bin = gh_bin.clone();
1027                let resolved = tokio::task::spawn_blocking(move || {
1028                    crate::pr_status::resolve_with_budget(&bin, &targets)
1029                })
1030                .await
1031                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1032                // Best-effort decoration: a missing/unauthenticated `gh`, a network
1033                // blip, or a rate limit must never sink the tree. A failed poll
1034                // leaves the last good resolutions in place — badges *and* explicit
1035                // negatives, and it mints no new negatives (#1370) — rather than
1036                // blanking every row, and is not "pending", so it backs off rather
1037                // than hammers.
1038                let (pending, resolved_ok) = match resolved {
1039                    Ok((resolutions, budget)) => {
1040                        // Fold the free budget reading this poll carried into the
1041                        // shared cache (#1389, fix 8): the graphql figure stays fresh
1042                        // whenever polling is active, which lets the standalone
1043                        // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
1044                        // reveals the real per-call point price.
1045                        if let Some(b) = budget {
1046                            tracing::debug!(
1047                                "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1048                                b.cost,
1049                                b.used,
1050                                b.limit,
1051                                b.remaining
1052                            );
1053                            rate_limit_cache.observe_graphql(RateLimitResource::new(
1054                                b.used,
1055                                b.limit,
1056                                b.remaining,
1057                                b.reset,
1058                            ));
1059                        }
1060                        // Bump only on a real change, or the server's diff-and-drop
1061                        // is defeated and every window re-renders on every poll.
1062                        if pr_cache.replace(resolutions) {
1063                            registry.bump();
1064                        }
1065                        (pr_cache.any_pending(), true)
1066                    }
1067                    Err(err) => {
1068                        tracing::debug!("PR badge poll failed: {err:#}");
1069                        (false, false)
1070                    }
1071                };
1072                last_poll = Some(Instant::now());
1073                // Record what this verdict was about, so the *next* tick can tell a
1074                // genuine change from a quiet tree.
1075                watched = Some(watch);
1076                let since_moved = moved_at.map(|at| at.elapsed());
1077                backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1078                // Persist the fresh verdicts (#1389, fix 4) so the next restart
1079                // serves them and can skip its immediate re-poll. Only on a real
1080                // resolution — a failed poll must not advance the persisted poll time
1081                // past the last *good* one. Best-effort; a write failure costs at
1082                // most one extra poll after the next restart.
1083                if resolved_ok {
1084                    if let Some(path) = &pr_cache_path {
1085                        persist_pr_cache(
1086                            path,
1087                            &pr_cache,
1088                            watched.as_deref().unwrap_or(&[]),
1089                            Utc::now(),
1090                        );
1091                    }
1092                }
1093            }
1094        });
1095        *guard = Some(PollerTask { token, handle });
1096    }
1097
1098    /// Starts the background task that keeps the GitHub API rate-limit reading
1099    /// fresh (#1375).
1100    ///
1101    /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1102    /// budget as every other tool sharing the user's token; when that drains, `gh`
1103    /// rate-limits machine-wide with no warning until commands start failing. This
1104    /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1105    /// project verified) as **exempt**, spending nothing against any budget — so
1106    /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1107    /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1108    ///
1109    /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1110    /// backoff and no window-gating, because the endpoint is free and a current
1111    /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1112    /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1113    /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1114    /// bare service that never spawns `gh`.
1115    pub fn start_rate_limit_poller(&self) {
1116        // Both resolved once at spawn: process-stable env config, never re-read per
1117        // poll (the PR-poller precedent).
1118        self.start_rate_limit_poller_with(
1119            rate_limit_poll_interval(),
1120            crate::pr_status::resolve_gh_binary(),
1121        );
1122    }
1123
1124    /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1125    /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1126    /// against a stub **without mutating the process environment** (the
1127    /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1128    fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1129        if tokio::runtime::Handle::try_current().is_err() {
1130            tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1131            return;
1132        }
1133        let mut guard = self
1134            .rate_limit_poller
1135            .lock()
1136            .unwrap_or_else(PoisonError::into_inner);
1137        if guard.is_some() {
1138            return;
1139        }
1140        let token = CancellationToken::new();
1141        let loop_token = token.clone();
1142        let cache = self.rate_limit_cache.clone();
1143        let registry = self.registry.clone();
1144        let handle = tokio::spawn(async move {
1145            // Remembers the previous reading so a WARN fires only on the *rising*
1146            // edge across the threshold, not every poll while usage stays high.
1147            let mut prev: Option<RateLimitSnapshot> = None;
1148            loop {
1149                // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1150                // no window registered and no polling lease active — has nothing to
1151                // watch, so it spends no `/rate_limit` subprocess and lets the last
1152                // reading stand. The read is free against the budget, but the
1153                // wakeups are not; and while polling *is* active the graphql figure
1154                // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1155                // standalone poll is only topping up `core`/`search`.
1156                if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1157                    // Poll first, so `status` has a reading soon after a window
1158                    // appears rather than one interval later. `gh` is a blocking
1159                    // subprocess: never on an async worker. A join failure folds into
1160                    // the same channel as a `gh` failure — both mean "no fresh
1161                    // reading this round".
1162                    let bin = gh_bin.clone();
1163                    let resolved =
1164                        tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1165                            .await
1166                            .unwrap_or_else(|err| {
1167                                Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1168                            });
1169                    match resolved {
1170                        Ok(snap) => {
1171                            if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1172                                // Bound to a local (rather than inlined into the
1173                                // macro) so it is computed whenever the branch is
1174                                // taken, not only when a WARN-level subscriber is
1175                                // installed — `tracing` skips evaluating macro args
1176                                // otherwise.
1177                                let summary = snap.summary_line();
1178                                tracing::warn!(
1179                                    "GitHub API rate limit high: {summary} (querying \
1180                                     /rate_limit is free; the daemon's gh usage is not)"
1181                                );
1182                            }
1183                            // Update the cache only; deliberately no `registry.bump()`
1184                            // — the rate limit is not tree topology, and bumping would
1185                            // re-push an unchanged tree to every window. The tray
1186                            // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1187                            cache.replace(snap);
1188                            prev = Some(snap);
1189                        }
1190                        // Best-effort decoration: a missing/unauthenticated `gh` or a
1191                        // network blip leaves the last good reading in place rather
1192                        // than blanking the line, and never affects the budget (the
1193                        // read is free).
1194                        Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1195                    }
1196                }
1197                tokio::select! {
1198                    () = loop_token.cancelled() => break,
1199                    () = tokio::time::sleep(interval) => {}
1200                }
1201            }
1202        });
1203        *guard = Some(PollerTask { token, handle });
1204    }
1205
1206    /// Handles the `close` op: close a worktree's window and, for a **linked**
1207    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1208    ///
1209    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1210    ///   [`git_safety`] check returning the risks of deleting, so the extension can
1211    ///   show a modal confirm only when something would actually be lost.
1212    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1213    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1214    ///   worktree. The main working tree is refused defensively.
1215    ///
1216    /// Cross-window signalling (another window has the target open) is a
1217    /// fast-follow: this core handles the **no-window** and **self-close**
1218    /// (`requester_key == target_key`) cases, and errors clearly when another
1219    /// window owns the target so the destructive path is never taken blind.
1220    async fn close(&self, req: CloseRequest) -> Result<Value> {
1221        // Which live windows currently have the target open. The canonical-path
1222        // compare is disk I/O, so run it (with the safety check below) on a
1223        // blocking thread, never under the registry lock or on the async worker.
1224        let entries = self.registry.list();
1225        let scan_path = req.path.clone();
1226        let open_windows =
1227            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1228                .await
1229                .unwrap_or_default();
1230        let open = !open_windows.is_empty();
1231        let window_key = open_windows.first().map(|(k, _)| k.clone());
1232        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1233
1234        // Phase 1: the safety check runs only for a delete request awaiting
1235        // confirmation. A "Close Window" (remove:false) never inspects git and
1236        // has nothing to confirm, so it skips straight to execute.
1237        if req.remove && !req.confirmed {
1238            let path = req.path.clone();
1239            let git = tokio::task::spawn_blocking(move || git_safety(&path))
1240                .await
1241                .map_err(|e| anyhow!("safety check task panicked: {e}"))
1242                .and_then(|inner| inner)
1243                .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1244            // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1245            // (#1364): the target, the owning window key (if any), whether a
1246            // window has it open, and the deletability verdict — with the
1247            // blocking risk kinds that force a confirm dialog, so a later "why did
1248            // the close prompt/refuse?" is answerable from the log alone.
1249            log_safety_check(&req.path, window_key.as_deref(), &git, open);
1250            return Ok(serde_json::to_value(SafetyReport {
1251                removable: git.removable,
1252                is_main: git.is_main,
1253                open,
1254                window_key,
1255                window_folder_count,
1256                risks: git.risks,
1257                info: git.info,
1258            })
1259            .unwrap_or_else(|_| json!({})));
1260        }
1261
1262        // Phase 2: execute. Signal every owning window *other than the
1263        // requester* (which closes itself on our `ok:true` reply, avoiding the
1264        // ext-host-dies-mid-op race) and wait for each to unregister before
1265        // touching the worktree. The directive reaches a cross-window target via
1266        // its heartbeat reply — the only channel the daemon has to a window it
1267        // can reply to but never call.
1268        let others: Vec<String> = open_windows
1269            .iter()
1270            .map(|(k, _)| k.clone())
1271            .filter(|k| req.requester_key.as_deref() != Some(k))
1272            .collect();
1273        // A self-close is the requester closing a window it owns: it never rides
1274        // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1275        // (#1364) so the execute's routing decision is auditable before the wait,
1276        // even if that wait then hangs or times out.
1277        let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1278        log_executing(
1279            &req.path,
1280            req.requester_key.as_deref(),
1281            req.remove,
1282            self_close,
1283            others.len(),
1284        );
1285        for key in &others {
1286            self.registry.mark_close_pending(key);
1287        }
1288        if !others.is_empty() {
1289            if let Err(err) = await_windows_closed(
1290                &self.registry,
1291                &req.path,
1292                req.requester_key.as_deref(),
1293                CLOSE_WAIT_TIMEOUT,
1294                CLOSE_WAIT_POLL,
1295            )
1296            .await
1297            {
1298                log_close_abort(&req.path, &err);
1299                return Err(err);
1300            }
1301        }
1302
1303        if req.remove {
1304            let path = req.path.clone();
1305            // The live window set, so a working-tree-gone-but-admin-present orphan
1306            // can find its owning main repo to prune (#1403); unused on the common
1307            // path where the checkout still exists.
1308            let entries = self.registry.list();
1309            // Taken *after* the wait above, so concurrent executes still overlap
1310            // their heartbeat waits (#1359) and only the prune itself serializes.
1311            // Load-bearing placement, not incidental: hoisting this above
1312            // `await_windows_closed` would restack the waits and undo the whole
1313            // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1314            let _guard = self.prune_lock.lock().await;
1315            let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1316                .await
1317                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1318                .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1319            // The audit line + Result→reply mapping lives in a sync helper so the
1320            // destructive outcome is unit-testable off the runtime (#1364).
1321            log_and_map_removal(&req.path, removed)
1322        } else {
1323            // "Close Window" with no owning window is a no-op success; a
1324            // self-close replies and the extension closes its own window.
1325            log_window_closed(&req.path);
1326            Ok(json!({ "closed": true }))
1327        }
1328    }
1329
1330    /// Handles the `reload` op (#1417): signal each target window to reload
1331    /// itself, returning `{ requested, signalled, unknown }`.
1332    ///
1333    /// Synchronous, and deliberately so — the whole op is a set insert per key.
1334    /// It marks a directive on each *currently registered* target and returns;
1335    /// the window acts on it on its next `heartbeat`, up to the ~10s cadence
1336    /// later. Unlike [`close`](Self::close) it never waits, because a reload has
1337    /// no completion the daemon can observe (the window re-registers under the
1338    /// same key), which is why the reply says `signalled`, never `reloaded`.
1339    ///
1340    /// A key with no live window is reported in `unknown` rather than erroring:
1341    /// the batch is a sweep, and a window closing between the client rendering
1342    /// its list and sending the op is routine, not a failure. `list()` reaps
1343    /// stale entries on read, so a window that died without unregistering is
1344    /// correctly unknown here.
1345    fn reload(&self, req: ReloadRequest) -> Value {
1346        let live: HashSet<String> = self
1347            .registry
1348            .list()
1349            .into_iter()
1350            .map(|entry| entry.key)
1351            .collect();
1352
1353        let mut seen = HashSet::new();
1354        let mut signalled = 0usize;
1355        let mut unknown = Vec::new();
1356        for key in &req.target_keys {
1357            // A client repeating a key asks for one reload, not two.
1358            if !seen.insert(key.as_str()) {
1359                continue;
1360            }
1361            if live.contains(key) {
1362                self.registry.mark_reload_pending(key);
1363                signalled += 1;
1364            } else {
1365                unknown.push(key.clone());
1366            }
1367        }
1368
1369        log_reload(seen.len(), signalled, &unknown);
1370        json!({
1371            "requested": seen.len(),
1372            "signalled": signalled,
1373            "unknown": unknown,
1374        })
1375    }
1376
1377    /// Handles the `merge-queue` op (#1401): batch-enqueue the eligible worktrees'
1378    /// PRs into the GitHub merge queue. Two-phase, keyed off `confirmed`:
1379    ///
1380    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run the
1381    ///   side-effect-free eligibility evaluation ([`evaluate_batch`]) and return an
1382    ///   [`EligibilityReport`]: the enqueue-eligible worktrees and the skipped ones
1383    ///   (each with a machine `kind` + human `detail`).
1384    /// - **Phase 2** (`confirmed:true`) — **re-run** the same evaluation (never
1385    ///   trust a phase-1 result the client sent, exactly as `close` re-validates on
1386    ///   execute), then enqueue each still-eligible PR. A per-PR rejection lands in
1387    ///   `failed[]`; the batch never fails as a whole.
1388    ///
1389    /// All git and `gh` I/O runs on a blocking thread — never the async worker and
1390    /// never under the registry lock. No lock is taken: each enqueue mutates a
1391    /// distinct remote PR, not a shared local resource.
1392    async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1393        // Resolved once here (the env read is process-stable), then handed to the
1394        // seam below — the `open_prs`/`open_prs_with` "bin as a param" pattern, so a
1395        // test drives the eligibility + enqueue paths against a fake `gh` without
1396        // touching the environment (#1030).
1397        self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1398            .await
1399    }
1400
1401    /// [`merge_queue`](Self::merge_queue) with an explicit `gh` binary, so a test
1402    /// exercises the phase-1 network resolve and the phase-2 enqueue against a stub.
1403    async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1404        // Report-only unless explicitly confirmed; an explicit `check` request
1405        // always reports and never enqueues.
1406        let report_only = req.check || !req.confirmed;
1407
1408        let eval_bin = bin.clone();
1409        let eval_paths = req.paths.clone();
1410        let (eligible, skipped) =
1411            tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1412                .await
1413                .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1414                .and_then(|inner| inner)?;
1415
1416        if report_only {
1417            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1418            log_merge_check(&req, eligible.len(), skipped.len());
1419            let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1420            return Ok(
1421                serde_json::to_value(EligibilityReport { eligible, skipped })
1422                    .unwrap_or_else(|_| json!({})),
1423            );
1424        }
1425
1426        // Phase 2: enqueue the freshly re-validated eligible set, sequentially.
1427        let enqueue_bin = bin.clone();
1428        let (queued, failed) =
1429            tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1430                .await
1431                .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1432        log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1433        Ok(serde_json::to_value(EnqueueResult {
1434            queued,
1435            skipped,
1436            failed,
1437        })
1438        .unwrap_or_else(|_| json!({})))
1439    }
1440
1441    /// Handles the `rebase` op (#1415): batch-rebase the selected worktrees onto
1442    /// their repository's remote default branch, fetching it **once per
1443    /// repository**. Two-phase, keyed off `confirmed`, exactly like `merge-queue`:
1444    ///
1445    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1446    ///   [`worktree_rebase::plan`], which fetches once per repo and classifies
1447    ///   every selected worktree. This *is* the "only rebase if it makes sense
1448    ///   from the current git state" gate: the classifier skips a detached HEAD, a
1449    ///   dirty tree, an operation already in progress, a non-worktree path, an
1450    ///   unresolvable onto ref, and anything already up to date — the main working
1451    ///   tree is a valid target like any other (ADR-0060). Side-effect-free apart
1452    ///   from the fetch, which only advances a remote-tracking ref.
1453    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch** (never trust a
1454    ///   phase-1 result the client sent back, as `close` and `merge-queue` do),
1455    ///   then execute. A worktree that went dirty between the phases is skipped
1456    ///   rather than rebased.
1457    ///
1458    /// **Why the daemon may do this at all** (ADR-0059): ADR-0055 confined the
1459    /// rebase to the CLI on the premise that the daemon could not authenticate a
1460    /// fetch. It can — launchd exports `SSH_AUTH_SOCK` into the per-user session,
1461    /// so the daemon inherits the user's `ssh-agent`. The real gap was the minimal
1462    /// `PATH`, closed by [`crate::git::resolve_git_binary`].
1463    ///
1464    /// All git I/O runs on a blocking thread, never the async worker and never
1465    /// under the registry lock.
1466    async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1467        // Resolved once here (the probe is process-stable), then handed to the
1468        // seam below — the `merge_queue_with` "bin as a param" pattern, so a test
1469        // drives both phases against a stub without touching the environment.
1470        self.rebase_with(req, crate::git::resolve_git_binary())
1471            .await
1472    }
1473
1474    /// [`rebase`](Self::rebase) with an explicit `git` binary, so a test exercises
1475    /// the plan and execute paths against a stub.
1476    async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1477        if req.paths.is_empty() {
1478            bail!("`rebase` requires at least one path");
1479        }
1480        // Report-only unless explicitly confirmed; an explicit `check` request
1481        // always reports and never rebases.
1482        let report_only = req.check || !req.confirmed;
1483        let opts = req.options(git_bin);
1484        let selection = Selection::Paths(req.paths.clone());
1485
1486        if report_only {
1487            // Phase 1: fetch once per repo and classify, rebase nothing. No lock —
1488            // it mutates no worktree, and a plan is allowed to race an execute.
1489            let plan = plan_rebase(&selection, &opts).await?;
1490            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1491            log_rebase_check(&req, &plan);
1492            return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1493        }
1494
1495        // Phase 2: re-plan and execute, serialized against other executes —
1496        // linked worktrees share one object database (see `rebase_lock`).
1497        //
1498        // The lock is taken **before** the re-plan, not merely around the execute,
1499        // and that ordering is load-bearing. A plan taken outside it can be
1500        // invalidated by a concurrent execute before this one gets its turn — and
1501        // acting on a stale plan is not benign: if the other run left a worktree
1502        // mid-rebase, a stale `WouldRebase` here would run `git rebase` against a
1503        // repository that is already mid-rebase and (without `keep_conflicts`)
1504        // `--abort` it, destroying exactly the conflict resolution the other run
1505        // was preserving. Planning under the lock means the classifier sees that
1506        // worktree's real state and skips it as `operation-in-progress`.
1507        let _guard = self.rebase_lock.lock().await;
1508        let plan = plan_rebase(&selection, &opts).await?;
1509        // The worktrees actually about to be rewritten, canonicalized here (disk
1510        // I/O belongs in the adapter, not the registry) so they match the tree
1511        // snapshot's own keys.
1512        let pending: Vec<PathBuf> = plan
1513            .worktrees
1514            .iter()
1515            .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1516            .map(|w| canonical(&w.path))
1517            .collect();
1518        self.registry.mark_rebasing(&pending);
1519        let fetches = plan.fetches.clone();
1520        let exec_opts = opts.clone();
1521        let outcomes =
1522            tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1523        // Cleared on **every** exit, including a panicked task, so a failed rebase
1524        // can never leave a permanent spinner on a tree row.
1525        self.registry.clear_rebasing(&pending);
1526        let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1527
1528        log_rebase_execute(&req, &outcomes);
1529        Ok(rebase_reply(&fetches, &outcomes))
1530    }
1531
1532    /// Handles the `push` op (#1443): publish the selected worktrees' branches to
1533    /// their upstreams, force-pushing **with a lease** where a rebase rewrote
1534    /// history. The complement of [`rebase`](Self::rebase), and two-phase in
1535    /// exactly the same way:
1536    ///
1537    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1538    ///   [`worktree_push::plan`], which classifies every selected worktree against
1539    ///   its upstream. This *is* the "does this make sense?" gate: it skips a
1540    ///   detached HEAD (which also covers a worktree mid-rebase), a non-worktree
1541    ///   path, a branch with nowhere to publish, and a force-push of the
1542    ///   repository's remote default branch — while a dirty tree is deliberately
1543    ///   **not** a skip, since a push publishes commits rather than the working
1544    ///   tree. Unlike `rebase`'s phase 1 this is not merely side-effect-*light*: it
1545    ///   contacts no remote at all (ADR-0061).
1546    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch**, then execute. A
1547    ///   phase-1 result the client sends back is never trusted, as `close`,
1548    ///   `merge-queue` and `rebase` all re-validate. A branch that moved between
1549    ///   the phases is re-classified, not pushed on stale information.
1550    ///
1551    /// **Why the daemon may do this at all** (ADR-0061): this is the first op to
1552    /// write to a remote using the user's ambient *git* credentials — `merge-queue`
1553    /// mutates the remote through `gh` (ADR-0056) and `rebase` only fetches
1554    /// (ADR-0059). It stays same-user-bounded behind the `0600` socket, publishes
1555    /// only branches the client names, and can never overwrite work it has not
1556    /// seen, because the lease is enforced by `git` itself.
1557    ///
1558    /// All git I/O runs on a blocking thread, never the async worker and never
1559    /// under the registry lock.
1560    async fn push(&self, req: PushRequest) -> Result<Value> {
1561        // Resolved once here (the probe is process-stable), then handed to the
1562        // seam below — the `rebase_with` "bin as a param" pattern.
1563        self.push_with(req, crate::git::resolve_git_binary()).await
1564    }
1565
1566    /// [`push`](Self::push) with an explicit `git` binary, so a test exercises the
1567    /// plan and execute paths against a stub.
1568    async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1569        if req.paths.is_empty() {
1570            bail!("`push` requires at least one path");
1571        }
1572        // Report-only unless explicitly confirmed; an explicit `check` request
1573        // always reports and never pushes.
1574        let report_only = req.check || !req.confirmed;
1575        let selection = Selection::Paths(req.paths.clone());
1576
1577        if report_only {
1578            // Phase 1: classify only. No lock and no network — planning reads the
1579            // local remote-tracking refs, which is exactly what the lease is
1580            // checked against.
1581            let plan = plan_push(&selection).await?;
1582            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1583            log_push_check(&req, &plan);
1584            return Ok(push_reply(&plan.worktrees));
1585        }
1586
1587        // Phase 2: re-plan and execute, serialized against other executes — a push
1588        // writes into the ref store linked worktrees share (see `push_lock`). The
1589        // lock is taken **before** the re-plan for the same reason `rebase` takes
1590        // its own that way: a plan plus a lease are only meaningful together, and a
1591        // plan taken outside the lock can be invalidated before its turn comes.
1592        let _guard = self.push_lock.lock().await;
1593        let plan = plan_push(&selection).await?;
1594        // The worktrees actually about to be published, canonicalized here (disk
1595        // I/O belongs in the adapter, not the registry) so they match the tree
1596        // snapshot's own keys.
1597        let pending: Vec<PathBuf> = plan
1598            .worktrees
1599            .iter()
1600            .filter(|w| w.result.is_pending())
1601            .map(|w| canonical(&w.path))
1602            .collect();
1603        self.registry.mark_pushing(&pending);
1604        let opts = worktree_push::PushOptions {
1605            git_bin: Some(git_bin),
1606        };
1607        let outcomes =
1608            tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1609        // Cleared on **every** exit, including a panicked task. A push writes no
1610        // on-disk state, so this set is the *whole* cue — a mark left behind would
1611        // be a permanent spinner nothing else could correct.
1612        self.registry.clear_pushing(&pending);
1613        let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1614
1615        log_push_execute(&req, &outcomes);
1616        Ok(push_reply(&outcomes))
1617    }
1618
1619    /// Handles the `reposition` op (#1407): move and resize each target worktree's
1620    /// **already-open** VS Code window to match the invoking window's geometry.
1621    ///
1622    /// The invoking window is the reference: it supplies the frame and is never
1623    /// itself moved. A target with no open window, no resolvable OS window, or an
1624    /// ambiguous name is reported rather than guessed at. Z-order is untouched —
1625    /// see [`geometry::ax`] for why the Accessibility API gives that for free, and
1626    /// why this must **not** reuse [`focus_window`], which deliberately raises.
1627    ///
1628    /// `check: true` is a dry run: everything resolves exactly as it would for a
1629    /// real run, but nothing is written. That is the whole diagnostic surface for
1630    /// title matching (`worktrees reposition --dry-run`).
1631    ///
1632    /// Not two-phase like `close`/`merge-queue`: nothing durable is created,
1633    /// modified, or destroyed, so a confirmation on a routine layout command would
1634    /// cost more than it protects. Reversibility is provided instead, by
1635    /// [`reposition_undo`](Self::reposition_undo).
1636    async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1637        self.reposition_with(req, geometry::ax::AxBackend::new)
1638            .await
1639    }
1640
1641    /// [`reposition`](Self::reposition) with the platform backend injected as a
1642    /// **factory**, so a test drives the whole op — key resolution, the undo
1643    /// store, the reply shape — against a fake with no `unsafe` and no windows.
1644    ///
1645    /// A factory rather than the backend itself because the real backend holds
1646    /// CoreFoundation references and so is neither `Send` nor `Sync`: it has to be
1647    /// built *inside* the blocking closure. The `merge_queue_with` "seam as a
1648    /// parameter" pattern, one level of indirection over.
1649    async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1650    where
1651        B: geometry::WindowBackend,
1652        F: FnOnce() -> B + Send + 'static,
1653    {
1654        if req.reference_key.trim().is_empty() {
1655            bail!("`reposition` requires a non-empty `reference_key`");
1656        }
1657        // Resolve keys against the registry *here*, so all AX work below deals in
1658        // plain data and the registry lock is never held across the blocking join.
1659        let entries = self.registry.list();
1660        let reference = registered_window(&entries, &req.reference_key);
1661        if !reference.live {
1662            // Unlike a target, an unresolvable *reference* is a hard error: there
1663            // is no geometry to copy, so the request cannot mean anything.
1664            bail!(
1665                "no open window with key {} (it may have closed)",
1666                req.reference_key
1667            );
1668        }
1669        // A target key with no live window is a reportable per-target outcome, not
1670        // a failure of the batch — the tree row may simply be a tick stale.
1671        let targets: Vec<geometry::RegisteredWindow> = req
1672            .target_keys
1673            .iter()
1674            .map(|key| registered_window(&entries, key))
1675            .collect();
1676
1677        let check = req.check;
1678        let mut report = tokio::task::spawn_blocking(move || {
1679            // The backend lives for exactly one op, so its enumeration cache can
1680            // never serve a window that has since moved or closed.
1681            let backend = make_backend();
1682            geometry::reposition(&backend, &reference, &targets, check)
1683        })
1684        .await
1685        .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1686
1687        // Taken out of the report rather than cloned: nothing downstream reads it,
1688        // and the store is its only owner. A dry run leaves the previous batch's
1689        // record intact — it changed nothing, so there is neither something new to
1690        // undo nor something stale to discard.
1691        let undo = std::mem::take(&mut report.undo);
1692        let undoable = !check && !undo.is_empty();
1693        if undoable {
1694            *self
1695                .reposition_undo
1696                .lock()
1697                .unwrap_or_else(PoisonError::into_inner) = undo;
1698        }
1699        log_reposition(&req, &report);
1700        Ok(reposition_reply(&report, undoable))
1701    }
1702
1703    /// Handles the `reposition-undo` op (#1407): put the windows the last
1704    /// `reposition` moved back where they were.
1705    ///
1706    /// Consumes the stored batch, so an undo cannot be replayed onto windows the
1707    /// user has since arranged by hand. Every window is re-resolved from scratch —
1708    /// one that has closed, been renamed, or gone fullscreen in the meantime is
1709    /// reported, not forced.
1710    async fn reposition_undo(&self) -> Result<Value> {
1711        self.reposition_undo_with(geometry::ax::AxBackend::new)
1712            .await
1713    }
1714
1715    /// [`reposition_undo`](Self::reposition_undo) with the backend factory
1716    /// injected, for the same reasons as
1717    /// [`reposition_with`](Self::reposition_with).
1718    async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1719    where
1720        B: geometry::WindowBackend,
1721        F: FnOnce() -> B + Send + 'static,
1722    {
1723        let stored = std::mem::take(
1724            &mut *self
1725                .reposition_undo
1726                .lock()
1727                .unwrap_or_else(PoisonError::into_inner),
1728        );
1729        if stored.is_empty() {
1730            return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1731        }
1732        let entries = self.registry.list();
1733        let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1734            .into_iter()
1735            .map(|(key, frame)| (registered_window(&entries, &key), frame))
1736            .collect();
1737
1738        let report = tokio::task::spawn_blocking(move || {
1739            let backend = make_backend();
1740            geometry::restore(&backend, &restore)
1741        })
1742        .await
1743        .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1744        log_reposition_undo(&report);
1745        Ok(reposition_reply(&report, false))
1746    }
1747}
1748
1749impl Default for WorktreesService {
1750    fn default() -> Self {
1751        Self::new()
1752    }
1753}
1754
1755#[async_trait]
1756impl DaemonService for WorktreesService {
1757    fn name(&self) -> &'static str {
1758        SERVICE_NAME
1759    }
1760
1761    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1762        match op {
1763            "register" => {
1764                let req: RegisterRequest =
1765                    serde_json::from_value(payload).context("invalid `register` payload")?;
1766                if req.key.trim().is_empty() {
1767                    bail!("`register` requires a non-empty `key`");
1768                }
1769                self.registry.register(req);
1770                Ok(json!({ "ok": true }))
1771            }
1772            "heartbeat" => {
1773                let key = require_str(&payload, "key", "heartbeat")?;
1774                let known = self.registry.heartbeat(key);
1775                // A pending close directive (#1277) rides the reply as an
1776                // additive `close` field, taken-and-cleared here so it fires
1777                // exactly once. Omitted when false to keep older windows — which
1778                // read only `known` — byte-identical on the wire.
1779                let mut reply = json!({ "known": known });
1780                if self.registry.take_close_pending(key) {
1781                    reply["close"] = Value::Bool(true);
1782                }
1783                // A pending reload directive (#1417) rides the same reply, on
1784                // the same terms. Deliberately an independent `if`, not an
1785                // `else`: each field then means exactly "this directive was
1786                // pending", and each is taken exactly once regardless of the
1787                // other. The companion resolves a both-set collision by
1788                // checking `close` first, since closing subsumes reloading.
1789                if self.registry.take_reload_pending(key) {
1790                    reply["reload"] = Value::Bool(true);
1791                }
1792                Ok(reply)
1793            }
1794            "unregister" => {
1795                let key = require_str(&payload, "key", "unregister")?;
1796                Ok(json!({ "removed": self.registry.unregister(key) }))
1797            }
1798            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1799            "tree" => {
1800                // The same `{ repos, show_closed }` snapshot the `subscribe`
1801                // stream pushes, so a one-shot `tree` fetch and the live stream
1802                // agree byte-for-byte (the git enumeration runs off-lock on a
1803                // blocking thread inside the helper). Computed fresh here — the
1804                // `tree` op is a rare manual refresh, deliberately bypassing the
1805                // stream's coalescing cache so it never returns a stale view
1806                // (#1303).
1807                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1808            }
1809            "ahead-behind" => {
1810                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1811                // snapshot no longer carries ahead/behind — the dominant
1812                // per-worktree cost when computed eagerly every tick — so a client
1813                // (the extension on expand, `worktrees tree`) asks for it here only
1814                // for the worktrees it is about to show. Batched by path, one op per
1815                // repo expand; the git walks run on a blocking thread.
1816                let paths = payload
1817                    .get("paths")
1818                    .and_then(Value::as_array)
1819                    .map(|arr| {
1820                        arr.iter()
1821                            .filter_map(Value::as_str)
1822                            .map(PathBuf::from)
1823                            .collect::<Vec<_>>()
1824                    })
1825                    .unwrap_or_default();
1826                Ok(json!({ "results": ahead_behind_results(&self.ahead_behind, paths).await }))
1827            }
1828            "set-show-closed" => {
1829                // The daemon-backed show/hide-closed toggle (#1301). Setting it
1830                // bumps the change-notify, so every subscribed window re-pushes a
1831                // snapshot carrying the new `show_closed` — reliable cross-window
1832                // sync `context.globalState` could not do.
1833                let show_closed = payload
1834                    .get("show_closed")
1835                    .and_then(Value::as_bool)
1836                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1837                self.registry.set_show_closed(show_closed);
1838                Ok(json!({ "ok": true }))
1839            }
1840            "set-polling" => {
1841                // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1842                // poller resolving its badges (default is off — zero `gh`);
1843                // disabling stops it and drops its badges. `set_polling` bumps the
1844                // change-notify on a real change, so every subscribed window
1845                // re-pushes a `tree` snapshot carrying the new per-repo
1846                // `polling_enabled` — the reliable cross-window sync the
1847                // `set-show-closed` precedent relies on. A changed value is
1848                // persisted so it survives a daemon restart.
1849                let owner = require_str(&payload, "owner", "set-polling")?;
1850                let name = require_str(&payload, "name", "set-polling")?;
1851                let enabled = payload
1852                    .get("enabled")
1853                    .and_then(Value::as_bool)
1854                    .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1855                if owner.trim().is_empty() || name.trim().is_empty() {
1856                    bail!("`set-polling` requires a non-empty `owner` and `name`");
1857                }
1858                if self.registry.set_polling(owner, name, enabled) {
1859                    self.persist_polling_prefs();
1860                }
1861                Ok(json!({ "ok": true }))
1862            }
1863            "open-prs" => {
1864                // Serve "Open Pull Request…" (and the extension's transient badge
1865                // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1866                // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1867                // call instead of each shelling its own (the per-window burn
1868                // #1370/#1389 target). Repo-wide; the client filters by branch for a
1869                // worktree-scoped lookup, and answers a badged branch straight from
1870                // the snapshot (zero `gh`) without ever reaching here.
1871                let owner = require_str(&payload, "owner", "open-prs")?;
1872                let name = require_str(&payload, "name", "open-prs")?;
1873                if owner.trim().is_empty() || name.trim().is_empty() {
1874                    bail!("`open-prs` requires a non-empty `owner` and `name`");
1875                }
1876                Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1877            }
1878            "open" => {
1879                // Focus (or open — VS Code reuses an already-open window) an
1880                // arbitrary worktree folder supplied by a socket client, reusing
1881                // the tray's launcher path: `focus_window` resolves the launcher
1882                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1883                // the absolute-existing-directory guard (which also blocks a
1884                // `-`-leading path being parsed by `code` as a flag). This is the
1885                // one op a socket *writer* can use to spawn `code`; see the
1886                // ADR-0040 threat model (#1266).
1887                let path = require_str(&payload, "path", "open")?;
1888                focus_window(Path::new(path))?;
1889                Ok(json!({ "ok": true }))
1890            }
1891            "close" => {
1892                // Close a worktree's window and (for a linked worktree)
1893                // **delete** it. Destructive, so all git logic stays in the
1894                // daemon (git2, never a shell) and the main working tree is
1895                // refused defensively — the UI gating is not the only guard.
1896                // See ADR-0049 and docs/worktrees-service.md.
1897                let req: CloseRequest =
1898                    serde_json::from_value(payload).context("invalid `close` payload")?;
1899                self.close(req).await
1900            }
1901            "reload" => {
1902                // Signal each target window to reload itself (#1417). Addressed
1903                // by window key like `reposition`, not by path like `close`: a
1904                // reload acts on a *window*, and one tree row is one window,
1905                // whereas a path can be open in several. Nothing here is
1906                // destructive and nothing waits — the directive is marked and
1907                // the reply says only what was *signalled*. See
1908                // docs/worktrees-service.md.
1909                let req: ReloadRequest =
1910                    serde_json::from_value(payload).context("invalid `reload` payload")?;
1911                Ok(self.reload(req))
1912            }
1913            "merge-queue" => {
1914                // Batch-enqueue eligible worktrees' PRs into the GitHub merge
1915                // queue (#1401). Two-phase like `close` (side-effect-free
1916                // eligibility check → confirmed enqueue) and daemon-re-validated,
1917                // but a single batched op over `paths`. All git/`gh` work runs on
1918                // a blocking thread; enqueue authenticates through the user's own
1919                // `gh`. See ADR-0056 and docs/worktrees-service.md.
1920                let req: MergeQueueRequest =
1921                    serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1922                self.merge_queue(req).await
1923            }
1924            "rebase" => {
1925                // Batch-rebase the selected worktrees onto their repo's remote
1926                // default branch, fetching once per repo (#1415). Two-phase like
1927                // `close`/`merge-queue` (side-effect-free plan → confirmed
1928                // execute) and daemon-re-validated. The fetch authenticates
1929                // through the user's own `ssh-agent`, which launchd exports into
1930                // the daemon's environment — the premise ADR-0055 got wrong. All
1931                // git work runs on a blocking thread. See ADR-0059, ADR-0055 and
1932                // docs/worktrees-service.md.
1933                let req: RebaseRequest =
1934                    serde_json::from_value(payload).context("invalid `rebase` payload")?;
1935                self.rebase(req).await
1936            }
1937            "push" => {
1938                // Publish the selected worktrees' branches to their upstreams,
1939                // force-pushing **with a lease** where a rebase rewrote history
1940                // (#1443). Two-phase like `rebase` (side-effect-free plan →
1941                // confirmed execute) and daemon-re-validated, but its plan phase
1942                // contacts no remote at all. The push authenticates through the
1943                // user's own ambient git credentials — the first op to *write* to
1944                // a remote that way. There is no force escape hatch and no remote
1945                // override: a refused lease is the feature working. See ADR-0061
1946                // and docs/worktrees-service.md.
1947                let req: PushRequest =
1948                    serde_json::from_value(payload).context("invalid `push` payload")?;
1949                self.push(req).await
1950            }
1951            "reposition" => {
1952                // Move each target's already-open VS Code window onto the invoking
1953                // window's geometry (#1407). The one op that reaches outside the
1954                // process to control another application's windows, so all of its
1955                // OS interaction is confined to the `geometry::ax` module behind
1956                // the macOS Accessibility permission — geometry only, never a
1957                // raise, so Z-order is untouched. All AX I/O runs on a blocking
1958                // thread. See ADR-0058 and docs/worktrees-service.md.
1959                let req: RepositionRequest =
1960                    serde_json::from_value(payload).context("invalid `reposition` payload")?;
1961                self.reposition(req).await
1962            }
1963            "reposition-undo" => {
1964                // Put the windows the last `reposition` moved back where they were
1965                // (#1407). Payload-free: the daemon holds the one-level undo
1966                // record, so the client cannot ask to restore arbitrary geometry.
1967                self.reposition_undo().await
1968            }
1969            other => bail!("unknown worktrees op: {other}"),
1970        }
1971    }
1972
1973    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1974        // The single streaming op: a live push of the repo/worktree `tree`
1975        // snapshot. Every other op falls through to the request→reply `handle`.
1976        if op != "subscribe" {
1977            return None;
1978        }
1979        Some(Box::new(WorktreesStream {
1980            // Every stream reads through the one shared cache, so N windows
1981            // sampling the same tick build the tree once, not N times (#1303).
1982            cache: self.tree_cache.clone(),
1983            // Capture the change source *now* — before the server takes its
1984            // initial snapshot — so a change racing that snapshot still wakes us.
1985            changes: self.registry.subscribe_changes(),
1986        }))
1987    }
1988
1989    fn menu(&self) -> MenuSnapshot {
1990        // Serve the snapshot the background task maintains off the main thread;
1991        // fall back to a one-off inline compute only before the first refresh
1992        // lands (or with no runtime — the unit tests). Never blocks on git here
1993        // in the daemon, honouring the trait's "cheap, must not block" contract.
1994        let cached = self
1995            .menu_cache
1996            .lock()
1997            .unwrap_or_else(PoisonError::into_inner)
1998            .clone();
1999        let items = cached.unwrap_or_else(|| {
2000            menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
2001        });
2002        MenuSnapshot {
2003            title: SUBMENU_TITLE.to_string(),
2004            items,
2005        }
2006    }
2007
2008    async fn menu_action(&self, action_id: &str) -> Result<()> {
2009        if let Some(key) = action_id.strip_prefix("focus:") {
2010            // The registry resolves the folder under its own lock and clones it
2011            // out, so the mutex is never held across the process launch.
2012            let folder = self
2013                .registry
2014                .first_folder(key)
2015                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2016            focus_window(&folder)?;
2017            return Ok(());
2018        }
2019        bail!("unknown worktrees menu action: {action_id}")
2020    }
2021
2022    async fn status(&self) -> ServiceStatus {
2023        let entries = self.registry.list();
2024        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2025        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2026        let windows = enriched_windows(entries).await;
2027        ServiceStatus {
2028            name: SERVICE_NAME.to_string(),
2029            healthy: true,
2030            summary,
2031            detail: json!({ "windows": windows }),
2032        }
2033    }
2034
2035    fn credit_accept_outage(&self, outage: Duration) {
2036        self.registry.credit_outage(outage);
2037    }
2038
2039    async fn shutdown(&self) {
2040        // Stop the background menu-refresh task; the registry itself is in-memory
2041        // with nothing to drain or persist. Take the task out from under the lock
2042        // first so the `std::Mutex` is never held across the `.await`.
2043        let task = self
2044            .refresh
2045            .lock()
2046            .unwrap_or_else(PoisonError::into_inner)
2047            .take();
2048        if let Some(task) = task {
2049            task.token.cancel();
2050            let _ = task.handle.await;
2051        }
2052        // Same discipline for the PR badge poller (#1337): take it out from under
2053        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
2054        let poller = self
2055            .poller
2056            .lock()
2057            .unwrap_or_else(PoisonError::into_inner)
2058            .take();
2059        if let Some(poller) = poller {
2060            poller.token.cancel();
2061            let _ = poller.handle.await;
2062        }
2063        // And the GitHub rate-limit poller (#1375), same discipline.
2064        let rate_limit_poller = self
2065            .rate_limit_poller
2066            .lock()
2067            .unwrap_or_else(PoisonError::into_inner)
2068            .take();
2069        if let Some(poller) = rate_limit_poller {
2070            poller.token.cancel();
2071            let _ = poller.handle.await;
2072        }
2073    }
2074}
2075
2076/// Extracts a required string `field` from an op payload, erroring with the op
2077/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
2078/// (`key`) and `open` (`path`).
2079fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2080    payload
2081        .get(field)
2082        .and_then(Value::as_str)
2083        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2084}
2085
2086/// The live git state of a worktree folder: the checked-out branch and how far
2087/// it has diverged from its upstream. Computed on read from the on-disk repo
2088/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
2089/// snapshot taken at registration.
2090///
2091/// Every field is optional and degrades independently: a folder that is not a
2092/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
2093/// listed — just without the fields it cannot supply. The `skip_serializing_if`
2094/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
2095/// omitting each absent field on the wire.
2096#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2097struct GitStatus {
2098    /// The checked-out branch, or `None` when detached or not in a repo.
2099    #[serde(skip_serializing_if = "Option::is_none")]
2100    branch: Option<String>,
2101    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
2102    /// even on a detached HEAD, which has a commit but no branch. Rides the
2103    /// streamed snapshot so a new commit is a real delta the server's diff cannot
2104    /// drop — without it, a push serialises byte-identically and no client
2105    /// re-renders (#1337).
2106    #[serde(skip_serializing_if = "Option::is_none")]
2107    head_sha: Option<String>,
2108    /// The commit the branch's configured upstream ref points at, or `None`
2109    /// without an upstream (or when detached, unborn, or not in a repo). Rides
2110    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
2111    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
2112    /// field — `head_sha` included — byte-identical, so without this the frame
2113    /// serialised the same, the server's diff dropped it, and the lazily-fetched
2114    /// ahead/behind was never re-asked (#1344).
2115    #[serde(skip_serializing_if = "Option::is_none")]
2116    upstream_sha: Option<String>,
2117    /// Commits the branch is ahead of its upstream (`None` without an upstream).
2118    #[serde(skip_serializing_if = "Option::is_none")]
2119    ahead: Option<usize>,
2120    /// Commits the branch is behind its upstream (`None` without an upstream).
2121    #[serde(skip_serializing_if = "Option::is_none")]
2122    behind: Option<usize>,
2123    /// The main repository's directory name — the parent repo for a linked
2124    /// worktree, the checkout's own directory otherwise. Derived from git's
2125    /// common dir so a worktree names the repo it belongs to rather than its
2126    /// worktree-folder basename. `None` when not in a repo.
2127    #[serde(skip_serializing_if = "Option::is_none")]
2128    main_repo: Option<String>,
2129    /// Whether the enriched folder is a **linked** git worktree rather than the
2130    /// repository's main working tree. Omitted (false) for a normal checkout.
2131    #[serde(skip_serializing_if = "is_false")]
2132    is_worktree: bool,
2133    /// The multi-step git operation the worktree is in the middle of (#1415):
2134    /// `rebase`, `rebase-interactive`, `merge`, `cherry-pick`, `revert`, `bisect`
2135    /// or `apply-mailbox`. `None` for a clean worktree (the overwhelming case), so
2136    /// the field is omitted on the wire and an older client is byte-identical.
2137    ///
2138    /// This is the **durable** half of the tree's rebase cue: read fresh off disk
2139    /// on every snapshot, it survives a daemon restart and keeps showing a
2140    /// conflict the `rebase` op left in place until the user resolves it. The
2141    /// transient half — "the daemon is rebasing this right now" — comes from the
2142    /// registry's in-memory set instead (see [`TreeWorktree::rebasing`]).
2143    ///
2144    /// Cheap enough for the every-worktree-every-tick snapshot (#1306's bar):
2145    /// `Repository::state()` stats a handful of paths under `.git`, which is
2146    /// nothing beside the `Repository::discover` this function already does — and
2147    /// unlike `graph_ahead_behind` it is neither a revwalk nor an object lookup.
2148    #[serde(skip_serializing_if = "Option::is_none")]
2149    operation: Option<String>,
2150}
2151
2152/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
2153/// field is dropped on the wire unless set — keeping older clients byte-identical
2154/// (the protocol's forward-compatibility convention).
2155#[allow(clippy::trivially_copy_pass_by_ref)]
2156fn is_false(b: &bool) -> bool {
2157    !*b
2158}
2159
2160/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
2161/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
2162/// daemon restart within the lease window keep the *remaining* time rather than
2163/// resetting the 15-minute clock; an already-expired entry is dropped on load.
2164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2165struct PollingLease {
2166    repo: String,
2167    expires_at: DateTime<Utc>,
2168}
2169
2170/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
2171/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
2172/// absence means not-polled (the default-off model) — so the file stays small (a
2173/// handful of active repos out of many open). `#[serde(default)]` so an
2174/// empty/older file decodes to "nothing enabled".
2175#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2176struct PollingPrefs {
2177    #[serde(default)]
2178    enabled: Vec<PollingLease>,
2179}
2180
2181/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
2182/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
2183/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
2184fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2185    if let Some(parent) = path.parent() {
2186        crate::daemon::paths::ensure_dir_0700(parent)?;
2187    }
2188    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2189    crate::daemon::paths::write_file_0600(path, &json)
2190}
2191
2192// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
2193
2194/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
2195///
2196/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
2197/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
2198/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
2199/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
2200/// compared against the worktree's current HEAD via
2201/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
2202/// `head_oid` would render every restored badge stale — so it carries the field
2203/// explicitly under a stable snake_case name.
2204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2205struct PersistedBadge {
2206    number: u64,
2207    is_draft: bool,
2208    checks: PrCheckState,
2209    url: String,
2210    head_oid: String,
2211}
2212
2213/// A persisted resolution — the disk twin of
2214/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
2215/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
2216/// `{ "Pr": { … } }`.
2217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2218enum PersistedResolution {
2219    Pr(PersistedBadge),
2220    NoPr,
2221}
2222
2223/// One persisted cache entry: which target, and the verdict last resolved for it.
2224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2225struct PersistedEntry {
2226    target: PrTarget,
2227    resolution: PersistedResolution,
2228}
2229
2230/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
2231/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
2232/// immediate re-poll" from "a target was added or a branch pushed while we were
2233/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
2234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2235struct PersistedWatch {
2236    target: PrTarget,
2237    #[serde(default, skip_serializing_if = "Option::is_none")]
2238    upstream_sha: Option<String>,
2239}
2240
2241/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
2242/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
2243/// so an empty/older/partial file decodes to "nothing restored" rather than
2244/// failing the load — best-effort, exactly like [`PollingPrefs`].
2245#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2246struct PrCachePrefs {
2247    #[serde(default)]
2248    entries: Vec<PersistedEntry>,
2249    #[serde(default)]
2250    watched: Vec<PersistedWatch>,
2251    #[serde(default, skip_serializing_if = "Option::is_none")]
2252    polled_at: Option<DateTime<Utc>>,
2253}
2254
2255impl PersistedResolution {
2256    /// The disk form of a live resolution.
2257    fn from_resolution(r: &PrResolution) -> Self {
2258        match r {
2259            PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2260                number: b.number,
2261                is_draft: b.is_draft,
2262                checks: b.checks,
2263                url: b.url.clone(),
2264                head_oid: b.head_oid.clone(),
2265            }),
2266            PrResolution::NoPr => Self::NoPr,
2267        }
2268    }
2269
2270    /// The live form of a restored resolution.
2271    fn into_resolution(self) -> PrResolution {
2272        match self {
2273            Self::Pr(b) => PrResolution::Pr(PrBadge {
2274                number: b.number,
2275                is_draft: b.is_draft,
2276                checks: b.checks,
2277                url: b.url,
2278                head_oid: b.head_oid,
2279            }),
2280            Self::NoPr => PrResolution::NoPr,
2281        }
2282    }
2283}
2284
2285/// Assembles the on-disk cache from the live cache entries, the watch they were
2286/// resolved for, and the poll time.
2287fn pr_cache_prefs_from(
2288    entries: Vec<(PrTarget, PrResolution)>,
2289    watched: &[PrWatch],
2290    polled_at: DateTime<Utc>,
2291) -> PrCachePrefs {
2292    let mut entries: Vec<PersistedEntry> = entries
2293        .into_iter()
2294        .map(|(target, resolution)| PersistedEntry {
2295            target,
2296            resolution: PersistedResolution::from_resolution(&resolution),
2297        })
2298        .collect();
2299    // Stable on-disk order so the file does not churn on rewrite from `HashMap`
2300    // iteration order alone (the same reason `PollingPrefs` sorts).
2301    entries.sort_by(|a, b| a.target.cmp(&b.target));
2302    let mut watched: Vec<PersistedWatch> = watched
2303        .iter()
2304        .map(|w| PersistedWatch {
2305            target: w.target.clone(),
2306            upstream_sha: w.upstream_sha.clone(),
2307        })
2308        .collect();
2309    watched.sort_by(|a, b| a.target.cmp(&b.target));
2310    PrCachePrefs {
2311        entries,
2312        watched,
2313        polled_at: Some(polled_at),
2314    }
2315}
2316
2317/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
2318/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
2319fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2320    if let Some(parent) = path.parent() {
2321        crate::daemon::paths::ensure_dir_0700(parent)?;
2322    }
2323    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2324    crate::daemon::paths::write_file_0600(path, &json)
2325}
2326
2327/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
2328/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
2329/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
2330/// in-memory cache is authoritative for the running daemon and losing the warm
2331/// start only costs one extra poll after the next restart.
2332fn persist_pr_cache(
2333    path: &Path,
2334    pr_cache: &PrStatusCache,
2335    watched: &[PrWatch],
2336    polled_at: DateTime<Utc>,
2337) {
2338    let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2339    if let Err(err) = write_pr_cache(path, &prefs) {
2340        let at = path.display();
2341        tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2342    }
2343}
2344
2345/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
2346/// the watch set the previous daemon last resolved and when it last polled. The
2347/// poller seeds its loop from this so a restart within the backoff window skips
2348/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
2349/// call to re-derive what the `0600` file already carries.
2350#[derive(Debug, Clone)]
2351struct PrWarmStart {
2352    /// The `(target, upstream_sha)` set the persisted verdicts describe.
2353    watched: Vec<PrWatch>,
2354    /// When those verdicts were resolved, used to age the warm start against the
2355    /// backoff (a stale-enough file just re-polls).
2356    polled_at: DateTime<Utc>,
2357}
2358
2359// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
2360
2361/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
2362/// fetched, for TTL expiry.
2363#[derive(Debug, Clone)]
2364struct OpenPrEntry {
2365    at: Instant,
2366    prs: Vec<Value>,
2367}
2368
2369/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
2370///
2371/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
2372/// from the daemon means N windows asking about one repo dedupe to a single counted
2373/// `gh pr list` within the TTL, instead of each window shelling its own (the
2374/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
2375/// the access pattern is a manual action (or a brief post-enable transient), so two
2376/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
2377/// and harmless, while the common repeat-within-TTL is served for free. The lock is
2378/// never held across an `.await`.
2379#[derive(Debug)]
2380struct OpenPrCache {
2381    entries: Mutex<HashMap<String, OpenPrEntry>>,
2382    ttl: Duration,
2383}
2384
2385impl OpenPrCache {
2386    fn new(ttl: Duration) -> Self {
2387        Self {
2388            entries: Mutex::new(HashMap::new()),
2389            ttl,
2390        }
2391    }
2392
2393    /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
2394    /// `None` (a miss that the caller resolves with a fresh `gh`).
2395    fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2396        self.entries
2397            .lock()
2398            .unwrap_or_else(PoisonError::into_inner)
2399            .get(key)
2400            .filter(|e| e.at.elapsed() < self.ttl)
2401            .map(|e| e.prs.clone())
2402    }
2403
2404    /// Records a freshly-fetched PR list for `key`.
2405    fn store(&self, key: String, prs: Vec<Value>) {
2406        self.entries
2407            .lock()
2408            .unwrap_or_else(PoisonError::into_inner)
2409            .insert(
2410                key,
2411                OpenPrEntry {
2412                    at: Instant::now(),
2413                    prs,
2414                },
2415            );
2416    }
2417}
2418
2419/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
2420/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
2421/// call on a blocking thread, never an async worker. The array is forwarded to the
2422/// extension verbatim, which parses it into its `PullRequest` shape.
2423fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2424    let output = crate::github_metrics::run_gh(
2425        bin,
2426        [
2427            "pr",
2428            "list",
2429            "--repo",
2430            slug,
2431            "--state",
2432            "open",
2433            "--json",
2434            OPEN_PR_JSON_FIELDS,
2435            "--limit",
2436            OPEN_PR_LIST_LIMIT,
2437        ],
2438        "pr list",
2439        None,
2440    )
2441    .with_context(|| {
2442        format!(
2443            "failed to run {} (is the GitHub CLI installed?)",
2444            bin.display()
2445        )
2446    })?;
2447    if !output.status.success() {
2448        let stderr = String::from_utf8_lossy(&output.stderr);
2449        bail!("gh pr list failed: {}", stderr.trim());
2450    }
2451    match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2452        Value::Array(arr) => Ok(arr),
2453        _ => bail!("gh pr list did not return a JSON array"),
2454    }
2455}
2456
2457/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
2458/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
2459/// op and the tray menu, both bounded to the (few) open windows, where the extra
2460/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
2461/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
2462fn git_status(folder: &Path) -> GitStatus {
2463    git_status_impl(folder, true)
2464}
2465
2466/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
2467/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
2468/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
2469/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
2470/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
2471/// fields stay `None`, so they are omitted on the wire exactly as for a branch
2472/// with no upstream.
2473fn git_status_cheap(folder: &Path) -> GitStatus {
2474    git_status_impl(folder, false)
2475}
2476
2477/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
2478/// repository that contains `folder` — so a subdirectory or a linked worktree both
2479/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
2480/// divergence. Every failure mode degrades to an empty status rather than
2481/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
2482fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2483    let Ok(repo) = Repository::discover(folder) else {
2484        return GitStatus::default();
2485    };
2486    // Repo identity applies even when HEAD is unborn or detached, so a worktree
2487    // still names its parent repo (and is flagged as a worktree) in those states.
2488    // The in-progress operation is read here too, for the same reason: a worktree
2489    // mid-rebase has a *detached* HEAD, so reading it any later would miss the one
2490    // state the cue exists to show.
2491    let base = GitStatus {
2492        main_repo: main_repo_name(repo.commondir()),
2493        is_worktree: repo.is_worktree(),
2494        operation: operation_slug(repo.state()),
2495        ..GitStatus::default()
2496    };
2497    let Ok(head) = repo.head() else {
2498        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
2499        return base;
2500    };
2501    // Resolved here — before the branch filter below, so a detached HEAD still
2502    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
2503    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
2504    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
2505    // rebuild (#1306's bar).
2506    let base = GitStatus {
2507        head_sha: head.target().map(|oid| oid.to_string()),
2508        ..base
2509    };
2510    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
2511    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
2512    // name — degrades to no branch through this one path.
2513    let Some(name) = head
2514        .shorthand()
2515        .ok()
2516        .filter(|_| head.is_branch())
2517        .map(str::to_string)
2518    else {
2519        return base;
2520    };
2521    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
2522    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
2523    // below costs the cheap path nothing, and is what gives it a handle to
2524    // resolve the upstream from.
2525    let branch = git2::Branch::wrap(head);
2526    // Unlike the divergence walk, this rides both paths: it is what makes a push
2527    // a visible delta (#1344).
2528    let upstream_sha = upstream_target(&branch);
2529    // The divergence walk is the dominant per-worktree cost, so the cheap path
2530    // skips it and leaves ahead/behind absent.
2531    let (ahead, behind) = if with_ahead_behind {
2532        match upstream_ahead_behind(&repo, &branch) {
2533            Some((ahead, behind)) => (Some(ahead), Some(behind)),
2534            None => (None, None),
2535        }
2536    } else {
2537        (None, None)
2538    };
2539    GitStatus {
2540        branch: Some(name),
2541        upstream_sha,
2542        ahead,
2543        behind,
2544        ..base
2545    }
2546}
2547
2548/// The stable kebab-case slug for a repository's in-progress operation, or `None`
2549/// when it is [`RepositoryState::Clean`] (#1415).
2550///
2551/// The three rebase flavours libgit2 distinguishes (`Rebase`, `RebaseInteractive`,
2552/// `RebaseMerge`) all collapse to `rebase-interactive` or `rebase`, because the
2553/// distinction is an implementation detail of how git is driving the rebase and
2554/// says nothing a user acting on the row would do differently. The `*Sequence`
2555/// variants likewise fold into their singular form. Everything a client does not
2556/// recognise still renders as "some operation in progress", which is the useful
2557/// floor.
2558fn operation_slug(state: RepositoryState) -> Option<String> {
2559    let slug = match state {
2560        RepositoryState::Clean => return None,
2561        RepositoryState::Merge => "merge",
2562        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2563        RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2564        RepositoryState::Bisect => "bisect",
2565        RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2566        RepositoryState::RebaseInteractive => "rebase-interactive",
2567        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2568    };
2569    Some(slug.to_string())
2570}
2571
2572/// The commit `branch`'s configured upstream ref points at, or `None` when it
2573/// tracks no upstream (or the ref is unresolvable).
2574///
2575/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
2576/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
2577/// refs read, but still **no revwalk and no object lookup**, which is the bar
2578/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
2579/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
2580/// same OID, so it is proven reachable.
2581fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2582    Some(branch.upstream().ok()?.get().target()?.to_string())
2583}
2584
2585/// The ahead/behind divergence of `repo`'s checked-out branch versus its
2586/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
2587/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
2588/// [`git_status_cheap`] omits. `None` when HEAD is detached or unborn, or the
2589/// branch tracks no upstream — every case the tree renders without a sync
2590/// indicator.
2591///
2592/// Takes an open repository so [`folder_divergence`] can answer this and
2593/// [`repo_main_behind`] from one `Repository::discover` (#2111).
2594fn repo_ahead_behind(repo: &Repository) -> Option<(usize, usize)> {
2595    let head = repo.head().ok()?;
2596    if !head.is_branch() {
2597        return None;
2598    }
2599    let branch = git2::Branch::wrap(head);
2600    upstream_ahead_behind(repo, &branch)
2601}
2602
2603/// Commits `repo`'s checked-out branch is behind the repository's remote
2604/// default branch (`origin/<main>`), computed on demand for the lazy
2605/// `ahead-behind` op (#1457). Resolves the default branch the same
2606/// **local-only, no-fetch** way `worktree_rebase::resolve_onto` resolves its
2607/// `--onto` default — via [`RemoteInfo::detect_main_branch_local`] — but,
2608/// unlike that resolver, never falls back to a hardcoded `"main"`: this is a
2609/// passive signal, so an unresolvable default branch means silence (`None`)
2610/// rather than a guess.
2611///
2612/// `None` when: HEAD is detached/unborn, no default branch is locally
2613/// resolvable, or the branch's own upstream **is** already that default branch —
2614/// in which case [`repo_ahead_behind`]'s `behind` already reports this exact
2615/// divergence, so repeating it here would just duplicate the existing sync count.
2616fn repo_main_behind(repo: &Repository) -> Option<usize> {
2617    let head = repo.head().ok()?;
2618    if !head.is_branch() {
2619        return None;
2620    }
2621    let branch = git2::Branch::wrap(head);
2622
2623    let remote = "origin";
2624    let default_branch = RemoteInfo::detect_main_branch_local(repo, remote)?;
2625    let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2626
2627    // Skip when the branch's own upstream already IS the resolved default
2628    // branch (the common checked-out-main/master case) — compared by ref name
2629    // so a fork's upstream on a *different* remote (e.g. `upstream/main`) is
2630    // never mistaken for it.
2631    if let Ok(upstream) = branch.upstream() {
2632        if upstream.get().name() == Ok(onto_ref.as_str()) {
2633            return None;
2634        }
2635    }
2636
2637    let head_oid = branch.get().target()?;
2638    let onto_oid = repo
2639        .revparse_single(&onto_ref)
2640        .ok()?
2641        .peel_to_commit()
2642        .ok()?
2643        .id();
2644    let (_ahead, behind) = divergence::graph_ahead_behind(repo, head_oid, onto_oid)?;
2645    Some(behind)
2646}
2647
2648/// Both lazily-fetched divergences of `folder`, from a single repository open.
2649/// What the `ahead-behind` op computes per worktree (#2111).
2650fn folder_divergence(folder: &Path) -> Divergence {
2651    let Ok(repo) = Repository::discover(folder) else {
2652        return Divergence::default();
2653    };
2654    Divergence {
2655        ahead_behind: repo_ahead_behind(&repo),
2656        main_behind: repo_main_behind(&repo),
2657    }
2658}
2659
2660/// The main repository's directory name from git's common dir. For the usual
2661/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
2662/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
2663/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
2664/// no name can be derived.
2665fn main_repo_name(commondir: &Path) -> Option<String> {
2666    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2667    if file_name == ".git" {
2668        // Normal layout: the repo is the directory that contains `.git`.
2669        commondir
2670            .parent()
2671            .and_then(Path::file_name)
2672            .map(|n| n.to_string_lossy().into_owned())
2673    } else {
2674        // A bare repo: use its own directory name, without any `.git` suffix.
2675        Some(
2676            file_name
2677                .strip_suffix(".git")
2678                .unwrap_or(&file_name)
2679                .to_string(),
2680        )
2681    }
2682}
2683
2684/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2685/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2686fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2687    let upstream = branch.upstream().ok()?;
2688    let local_oid = branch.get().target()?;
2689    let upstream_oid = upstream.get().target()?;
2690    divergence::graph_ahead_behind(repo, local_oid, upstream_oid)
2691}
2692
2693/// The wire shape of an enriched window: the stored entry fields plus the
2694/// daemon-computed git state, flattened into one JSON object. Serializing
2695/// through a single struct (rather than mutating a `Value`) keeps every present
2696/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2697/// the absent git fields — no manual per-field insertion.
2698#[derive(Serialize)]
2699struct EnrichedEntry<'a> {
2700    #[serde(flatten)]
2701    entry: &'a WindowEntry,
2702    #[serde(flatten)]
2703    git: GitStatus,
2704}
2705
2706/// Serializes a registry entry and folds in the live [`git_status`] of its
2707/// primary (first) folder, producing the JSON object served on the wire
2708/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2709/// enriched — it is the one the table shows and the "focus" action opens.
2710fn enriched_entry(entry: &WindowEntry) -> Value {
2711    let git = entry
2712        .folders
2713        .first()
2714        .map(|folder| git_status(folder))
2715        .unwrap_or_default();
2716    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2717}
2718
2719/// Enriches a batch of entries with their git state on a blocking thread, since
2720/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2721/// handler. A join failure degrades to an empty list rather than erroring.
2722async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2723    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2724        .await
2725        .unwrap_or_default()
2726}
2727
2728// --- Repo/worktree tree (#1265) ----------------------------------------------
2729
2730/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2731/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2732/// non-GitHub (or remote-less) repo omits it.
2733#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2734struct GithubIdentity {
2735    /// The repository owner (user or org) — the first path segment.
2736    owner: String,
2737    /// The repository name, with any `.git` suffix stripped.
2738    name: String,
2739}
2740
2741/// One worktree of a repository in the `tree` payload: its path, live git state,
2742/// whether it is the main working tree, and whether a VS Code window currently
2743/// has it open (with that window's key, for the focus action). Optional git
2744/// fields degrade independently, exactly like [`GitStatus`].
2745///
2746/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2747/// the dominant per-worktree cost when computed eagerly for every worktree on
2748/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2749/// worktrees a client actually shows (#1306).
2750///
2751/// The two **OIDs** the divergence is computed from — `head_sha` and
2752/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2753/// refs read rather than a commit-graph walk, and between them they are what makes
2754/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2755/// re-ask for the counts it left behind.
2756#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2757struct TreeWorktree {
2758    /// Absolute path to the worktree's working directory.
2759    path: String,
2760    /// The checked-out branch, or `None` when detached or unborn.
2761    #[serde(skip_serializing_if = "Option::is_none")]
2762    branch: Option<String>,
2763    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2764    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2765    /// commit a visible delta, so a push re-renders instead of being dropped by the
2766    /// server's snapshot diff (#1337).
2767    #[serde(skip_serializing_if = "Option::is_none")]
2768    head_sha: Option<String>,
2769    /// The commit the branch's upstream ref points at, or `None` without an
2770    /// upstream. The push counterpart of `head_sha`: a push moves only
2771    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2772    /// making the snapshot a real delta the server's diff cannot drop, which is
2773    /// what re-fetches the lazy ahead/behind (#1344).
2774    #[serde(skip_serializing_if = "Option::is_none")]
2775    upstream_sha: Option<String>,
2776    /// Whether this is the repository's main working tree (vs a linked worktree).
2777    is_main: bool,
2778    /// Whether a live VS Code window currently has this worktree open.
2779    open: bool,
2780    /// The open window's registry key, when `open` — the handle a focus action
2781    /// resolves. Absent for a worktree with no open window.
2782    #[serde(skip_serializing_if = "Option::is_none")]
2783    window_key: Option<String>,
2784    /// The open PR whose head is this worktree's branch, with its CI verdict
2785    /// (#1337). Resolved by the daemon's background poller and folded on as the
2786    /// snapshot is built, so every open window sees the same live state without
2787    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2788    /// with no open PR (see `pr_none`), or until the first poll lands.
2789    #[serde(skip_serializing_if = "Option::is_none")]
2790    pr: Option<PrBadge>,
2791    /// Set when the daemon **checked GitHub and found no open PR** for this
2792    /// worktree's branch — the explicit negative (#1370), mutually exclusive
2793    /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2794    /// non-GitHub worktree, and — crucially — while the branch is simply **not
2795    /// yet resolved** (before the first poll lands, or ever, on a failed one):
2796    /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2797    /// older client stays byte-identical (ADR-0053). Clients use it to keep
2798    /// their degraded per-window `gh pr list` fallback quiet for branches the
2799    /// daemon has already answered for.
2800    #[serde(skip_serializing_if = "is_false")]
2801    pr_none: bool,
2802    /// The multi-step git operation this worktree is mid-way through, when any
2803    /// (#1415) — see [`GitStatus::operation`]. The **durable** half of the rebase
2804    /// cue: a conflict the `rebase` op left in place shows here until it is
2805    /// resolved, across daemon restarts. Omitted for a clean worktree.
2806    #[serde(skip_serializing_if = "Option::is_none")]
2807    operation: Option<String>,
2808    /// Whether the daemon is rebasing this worktree **right now** (#1415) — the
2809    /// **transient** half of the cue, from the registry's in-memory set.
2810    ///
2811    /// Not redundant with `operation`: a rebase that applies cleanly never leaves
2812    /// an on-disk state for `operation` to report, and even one that conflicts
2813    /// only writes it at the moment of collision — so without this a multi-second
2814    /// rebase would render as nothing happening at all. Omitted (false) for the
2815    /// common case, keeping an older client byte-identical.
2816    #[serde(skip_serializing_if = "is_false")]
2817    rebasing: bool,
2818    /// Whether the daemon is pushing this worktree **right now** (#1443) — the
2819    /// `rebasing` twin, from the registry's other in-flight set.
2820    ///
2821    /// Unlike a rebase this cue has **no durable half**: a push writes no on-disk
2822    /// state, so there is nothing for a later snapshot to rediscover and this flag
2823    /// is the whole of it. A *completed* push instead shows up as `upstream_sha`
2824    /// moving, which is why that field rides the snapshot (#1344). Omitted (false)
2825    /// for the common case, keeping an older client byte-identical.
2826    #[serde(skip_serializing_if = "is_false")]
2827    pushing: bool,
2828}
2829
2830/// The registry's two transient in-flight sets, read together into one tree
2831/// snapshot (#1443).
2832///
2833/// Grouped rather than threaded as two parameters through
2834/// [`worktree_entry`]/[`repo_tree`]/[`build_tree`]/[`tree_repos`]: they are always
2835/// read at the same moment, from the same registry, for the same snapshot, and a
2836/// third would otherwise mean a fifth positional `HashSet` at every level.
2837#[derive(Debug, Clone, Default)]
2838struct InFlight {
2839    /// Worktree paths the `rebase` op is executing on (#1415).
2840    rebasing: HashSet<PathBuf>,
2841    /// Worktree paths the `push` op is executing on (#1443).
2842    pushing: HashSet<PathBuf>,
2843}
2844
2845impl InFlight {
2846    /// Reads both sets off the registry. Two short lock acquisitions, neither held
2847    /// across an `.await`; the pair need not be atomic, since each cue is
2848    /// independently true or false of a given row.
2849    fn read(registry: &WorktreesRegistry) -> Self {
2850        Self {
2851            rebasing: registry.rebasing_paths(),
2852            pushing: registry.pushing_paths(),
2853        }
2854    }
2855}
2856
2857/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2858/// derived from the distinct open windows; a repo leaves the tree when its last
2859/// window closes (the open-window-derived model, ADR-0040 / #1264).
2860#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2861struct TreeRepo {
2862    /// The main repository's directory name (see [`main_repo_name`]).
2863    main_repo: String,
2864    /// The GitHub identity of `origin`, when it is a `github.com` remote.
2865    #[serde(skip_serializing_if = "Option::is_none")]
2866    github: Option<GithubIdentity>,
2867    /// Absolute path to the main working tree — the repo's root.
2868    root: String,
2869    /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2870    /// registry's per-repo enable set, which defaults **off**, so it is omitted
2871    /// (false) for the common not-polled repo — keeping older clients
2872    /// byte-identical — and present (`true`) only for a repo the user has
2873    /// explicitly enabled. The extension colours the repo icon green when set and
2874    /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2875    /// on it so a not-polled repo issues zero `gh`.
2876    #[serde(skip_serializing_if = "is_false")]
2877    polling_enabled: bool,
2878    /// Every worktree of the repo: the main working tree first, then linked
2879    /// worktrees sorted by path.
2880    worktrees: Vec<TreeWorktree>,
2881}
2882
2883/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2884/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2885/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2886/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2887/// slashes are stripped; anything with an empty or extra path segment is
2888/// rejected (best-effort, never panics).
2889fn github_identity(url: &str) -> Option<GithubIdentity> {
2890    let url = url.trim();
2891    // Reduce every supported form to the `owner/name…` tail after the host.
2892    let rest = [
2893        "https://github.com/",
2894        "http://github.com/",
2895        "ssh://git@github.com/",
2896        "git://github.com/",
2897        "git@github.com:",
2898    ]
2899    .iter()
2900    .find_map(|prefix| url.strip_prefix(prefix))?;
2901    let rest = rest.strip_suffix(".git").unwrap_or(rest);
2902    let rest = rest.trim_end_matches('/');
2903    let mut parts = rest.splitn(2, '/');
2904    let owner = parts.next()?.trim();
2905    let name = parts.next()?.trim();
2906    // A well-formed identity has exactly two non-empty segments.
2907    if owner.is_empty() || name.is_empty() || name.contains('/') {
2908        return None;
2909    }
2910    Some(GithubIdentity {
2911        owner: owner.to_string(),
2912        name: name.to_string(),
2913    })
2914}
2915
2916/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2917/// `github.com` remote found. `None` when no remote is a GitHub remote.
2918fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2919    if let Ok(origin) = repo.find_remote("origin") {
2920        if let Some(id) = origin.url().ok().and_then(github_identity) {
2921            return Some(id);
2922        }
2923    }
2924    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2925    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2926    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2927    let names = repo.remotes().ok();
2928    names
2929        .iter()
2930        .flat_map(|arr| arr.iter())
2931        .flatten()
2932        .flatten()
2933        .filter_map(|name| repo.find_remote(name).ok())
2934        .find_map(|remote| remote.url().ok().and_then(github_identity))
2935}
2936
2937/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2938/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2939/// no longer exists) so the join still degrades gracefully.
2940fn canonical(path: &Path) -> PathBuf {
2941    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2942}
2943
2944/// Indexes the open windows by canonicalized workspace-folder path → window key,
2945/// so a worktree path can be joined back to the window (if any) that has it open.
2946/// The first window wins a shared folder; `entries` arrive in a deterministic
2947/// (repo, key) order, so the choice is stable.
2948fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2949    let mut index = HashMap::new();
2950    for entry in entries {
2951        for folder in &entry.folders {
2952            index
2953                .entry(canonical(folder))
2954                .or_insert_with(|| entry.key.clone());
2955        }
2956    }
2957    index
2958}
2959
2960/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2961/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2962/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2963/// by the caller from the enumeration (main working tree vs linked).
2964fn worktree_entry(
2965    path: &Path,
2966    is_main: bool,
2967    open_index: &HashMap<PathBuf, String>,
2968    in_flight: &InFlight,
2969) -> TreeWorktree {
2970    let status = git_status_cheap(path);
2971    let canonical = canonical(path);
2972    let window_key = open_index.get(&canonical).cloned();
2973    TreeWorktree {
2974        path: path.display().to_string(),
2975        branch: status.branch,
2976        head_sha: status.head_sha,
2977        upstream_sha: status.upstream_sha,
2978        is_main,
2979        open: window_key.is_some(),
2980        window_key,
2981        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2982        // identity — known one level up, in `repo_tree`.
2983        pr: None,
2984        pr_none: false,
2985        operation: status.operation,
2986        // Every in-flight cue is joined on the *canonical* path: the registry sets
2987        // are canonicalized by the adapter when an op marks them.
2988        rebasing: in_flight.rebasing.contains(&canonical),
2989        pushing: in_flight.pushing.contains(&canonical),
2990    }
2991}
2992
2993/// Folds the poller's cached PR resolutions onto each worktree of each repo
2994/// (#1337).
2995///
2996/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2997/// identity, branch) and the identity is only known once the repo is assembled.
2998/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2999/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
3000/// keeps `pr: None`/`pr_none: false` and renders nothing.
3001///
3002/// A verdict computed for a **different commit** than the worktree has checked out
3003/// is downgraded to pending here rather than shown as-is. That is what makes a push
3004/// invalidate the badge the moment it happens: the cache still holds the previous
3005/// commit's verdict, and this fold — which runs on every snapshot — notices without
3006/// waiting for a poll. Without it the previous head's `✓` stands until the poller
3007/// next runs, which is up to the full backoff.
3008///
3009/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
3010/// when `head_sha` moves: it has no commit to be stale against, and dropping it
3011/// would re-arm every client's `gh` fallback on every local commit. The poller's
3012/// `moved` trigger re-checks the branch within one fast poll anyway.
3013/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
3014/// enable set (#1376).
3015///
3016/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
3017/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
3018/// enabled carries neither the flag nor any badge. Purely a set membership check —
3019/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
3020/// and stays `false`; it never polls anyway.
3021fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3022    for repo in repos {
3023        if let Some(github) = &repo.github {
3024            repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3025        }
3026    }
3027}
3028
3029fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3030    for repo in repos {
3031        // A not-polled repo (#1376) never carries a badge: skip it so a repo the
3032        // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
3033        // the flag, and so the icon greys cross-window on the next pushed snapshot.
3034        if !repo.polling_enabled {
3035            continue;
3036        }
3037        let Some(github) = repo.github.clone() else {
3038            continue;
3039        };
3040        for worktree in &mut repo.worktrees {
3041            let Some(branch) = &worktree.branch else {
3042                continue;
3043            };
3044            match pr_cache.get(&github.owner, &github.name, branch) {
3045                Some(PrResolution::Pr(mut badge)) => {
3046                    if badge.is_stale_for(worktree.head_sha.as_deref()) {
3047                        badge.checks = PrCheckState::Pending;
3048                    }
3049                    worktree.pr = Some(badge);
3050                }
3051                Some(PrResolution::NoPr) => worktree.pr_none = true,
3052                None => {}
3053            }
3054        }
3055    }
3056}
3057
3058/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
3059/// handle discovered from one of its folders. Opens the **main** repo from the
3060/// shared common dir's parent so the main working tree and every linked worktree
3061/// are enumerated regardless of which one seeded the discovery. `None` for a
3062/// bare or otherwise root-less repo (no working tree to show).
3063fn repo_tree(
3064    discovered: &Repository,
3065    open_index: &HashMap<PathBuf, String>,
3066    in_flight: &InFlight,
3067) -> Option<TreeRepo> {
3068    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
3069    // linked worktrees; its parent is the main working tree.
3070    let commondir = canonical(discovered.commondir());
3071    let main_root = commondir.parent()?.to_path_buf();
3072    let main_repo = Repository::open(&main_root).ok()?;
3073
3074    // Main working tree first.
3075    let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3076    // Then every linked worktree, sorted by path for deterministic output. The
3077    // `StringArray` of names is bound so `iter()` can borrow it (only
3078    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
3079    // worktree is skipped.
3080    let names = main_repo.worktrees().ok();
3081    let mut linked: Vec<PathBuf> = names
3082        .iter()
3083        .flat_map(|arr| arr.iter())
3084        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3085        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3086        .filter_map(|name| main_repo.find_worktree(name).ok())
3087        .map(|wt| wt.path().to_path_buf())
3088        .collect();
3089    linked.sort();
3090    worktrees.extend(
3091        linked
3092            .iter()
3093            .map(|path| worktree_entry(path, false, open_index, in_flight)),
3094    );
3095
3096    Some(TreeRepo {
3097        main_repo: main_repo_name(&commondir)?,
3098        github: remote_github_identity(&main_repo),
3099        root: main_root.display().to_string(),
3100        // Defaults off; `stamp_polling` sets it from the registry's enable set
3101        // once the repo (and thus its GitHub identity) is assembled.
3102        polling_enabled: false,
3103        worktrees,
3104    })
3105}
3106
3107/// Resolves the seed `folders` to their distinct repositories and enumerates
3108/// each repo's worktrees. Dedupes repos by their common dir (shared across a
3109/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
3110/// is not in a git repo is skipped. Pure blocking git I/O — call it via
3111/// [`tree_repos`], never under the registry lock.
3112fn build_tree(
3113    folders: Vec<PathBuf>,
3114    windows: Vec<WindowEntry>,
3115    in_flight: InFlight,
3116) -> Vec<TreeRepo> {
3117    let open_index = open_window_index(&windows);
3118    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3119    for folder in &folders {
3120        let Ok(repo) = Repository::discover(folder) else {
3121            continue;
3122        };
3123        let key = canonical(repo.commondir());
3124        if repos.contains_key(&key) {
3125            continue;
3126        }
3127        if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3128            repos.insert(key, tree);
3129        }
3130    }
3131    repos.into_values().collect()
3132}
3133
3134/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
3135/// does synchronous disk I/O and this runs inside the async control-socket
3136/// handler), returning the serialized `repos` array. A join failure degrades to
3137/// an empty list rather than erroring, matching [`enriched_windows`].
3138async fn tree_repos(
3139    folders: Vec<PathBuf>,
3140    windows: Vec<WindowEntry>,
3141    pr_cache: Arc<PrStatusCache>,
3142    enabled_polling: HashSet<String>,
3143    in_flight: InFlight,
3144) -> Vec<Value> {
3145    tokio::task::spawn_blocking(move || {
3146        let mut repos = build_tree(folders, windows, in_flight);
3147        // Stamp per-repo poll state first so `fold_pr_badges` can skip a
3148        // not-polled repo — a disabled repo carries neither the flag nor a badge.
3149        stamp_polling(&mut repos, &enabled_polling);
3150        fold_pr_badges(&mut repos, &pr_cache);
3151        repos
3152            .iter()
3153            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3154            .collect()
3155    })
3156    .await
3157    .unwrap_or_default()
3158}
3159
3160// --- Lazy ahead/behind (#1306) -----------------------------------------------
3161
3162/// The wire shape of one worktree's lazily-fetched divergence: `ahead`/`behind`
3163/// (from its own upstream, folded in together or not at all — they come from one
3164/// `graph_ahead_behind` call) and `main_behind` (from the repo's remote default
3165/// branch, #1457), each independently optional. `main_behind` can be present
3166/// when `ahead`/`behind` are absent (no upstream at all) or absent when they are
3167/// present (the branch's own upstream already *is* the default branch).
3168#[derive(Serialize)]
3169struct AheadBehindEntry {
3170    #[serde(skip_serializing_if = "Option::is_none")]
3171    ahead: Option<usize>,
3172    #[serde(skip_serializing_if = "Option::is_none")]
3173    behind: Option<usize>,
3174    #[serde(skip_serializing_if = "Option::is_none")]
3175    main_behind: Option<usize>,
3176}
3177
3178/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
3179/// returning a JSON object keyed by the **requested** path string:
3180/// `{ "<path>": { "ahead"?, "behind"?, "main_behind"? }, … }`. A row is **omitted**
3181/// when neither the upstream divergence nor the main-branch divergence resolves
3182/// (not a repo, detached/unborn HEAD) — the client renders it without a sync
3183/// indicator, exactly as before #1457. A row can otherwise carry any subset of the
3184/// three fields: `main_behind` alone (no upstream, but behind the default branch),
3185/// `ahead`/`behind` alone (upstream *is* the default branch, so `main_behind` is
3186/// skipped), or all three together.
3187///
3188/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
3189/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
3190/// shows (the extension on expand), not for every worktree on every tick.
3191///
3192/// Every window re-asks for the same worktrees on each pushed delta, so the work
3193/// goes through `coordinator` (#2111): concurrent requests for one worktree share a
3194/// single computation, at most a few run at once, and each runs on a blocking
3195/// thread (the walks are disk I/O). A computation that fails degrades to "no
3196/// divergence" for that one worktree, which omits its row, rather than erroring.
3197async fn ahead_behind_results(coordinator: &AheadBehindCoordinator, paths: Vec<PathBuf>) -> Value {
3198    let divergences = futures::future::join_all(
3199        paths
3200            .iter()
3201            .map(|path| coordinator.get_or_compute(path.clone(), |p| folder_divergence(&p))),
3202    )
3203    .await;
3204    let mut results = serde_json::Map::new();
3205    for (path, divergence) in paths.iter().zip(divergences) {
3206        let (ahead, behind) = divergence
3207            .ahead_behind
3208            .map_or((None, None), |(a, b)| (Some(a), Some(b)));
3209        let main_behind = divergence.main_behind;
3210        if ahead.is_none() && main_behind.is_none() {
3211            continue;
3212        }
3213        results.insert(
3214            path.display().to_string(),
3215            json!(AheadBehindEntry {
3216                ahead,
3217                behind,
3218                main_behind,
3219            }),
3220        );
3221    }
3222    Value::Object(results)
3223}
3224
3225// --- Push subscription (#1267) -----------------------------------------------
3226
3227/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
3228/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
3229/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
3230/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
3231/// type only has to (a) relay the registry's change-notify and (b) read the
3232/// tree snapshot on demand.
3233///
3234/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
3235/// is built at most once per tick and fanned out, rather than each stream
3236/// rebuilding the identical tree. This type holds only cheap handles — a clone
3237/// of the shared cache and its own change-notify receiver.
3238struct WorktreesStream {
3239    /// The shared coalescing cache the snapshot is read through, so every
3240    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
3241    cache: Arc<TreeSnapshotCache>,
3242    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
3243    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
3244    /// server's diff drops any snapshot that ends up identical.
3245    changes: watch::Receiver<u64>,
3246}
3247
3248#[async_trait]
3249impl ServiceStream for WorktreesStream {
3250    async fn changed(&mut self) {
3251        // `watch::Receiver::changed` marks the newest version seen, so a burst of
3252        // bumps collapses into a single wakeup. If every sender is gone (the
3253        // registry — and thus the daemon — is tearing down) it returns `Err`;
3254        // park instead of returning, so this arm can never spin the server's
3255        // `select!` (the tick and shutdown arms still drive teardown).
3256        if self.changes.changed().await.is_err() {
3257            std::future::pending::<()>().await;
3258        }
3259    }
3260
3261    async fn snapshot(&self) -> Value {
3262        // Read through the shared coalescing cache. The value is built by the
3263        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
3264        // live push agree byte-for-byte — but here it is built once per tick and
3265        // shared across every subscriber rather than rebuilt per stream (#1303).
3266        self.cache.snapshot().await
3267    }
3268}
3269
3270/// A coalescing cache for the global tree snapshot (#1303).
3271///
3272/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
3273/// server re-samples each on its own `STREAM_TICK` and on every registry change
3274/// — so with N windows the *identical* global tree was being built N times per
3275/// tick. This cache collapses that to **one** build: all streams share it, and
3276/// it rebuilds at most once per `ttl` (the stream tick) per registry
3277/// change-generation.
3278///
3279/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
3280/// exactly as before:
3281/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
3282///   still matches — a `register`/`unregister`/toggle bumps it and forces a
3283///   fresh build, so subscribers never see a stale visible set; and
3284/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
3285///   branch switch, new commits), which fires no registry event, still surfaces
3286///   within one tick.
3287///
3288/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
3289/// callers, so a burst of N streams waking on the same tick/change performs one
3290/// build while the rest wait and read the shared result. The one-shot `tree` op
3291/// bypasses this and computes fresh — it is a rare manual refresh, not part of
3292/// the per-tick fan-out.
3293struct TreeSnapshotCache {
3294    /// The registry every snapshot is built from, and whose change-generation
3295    /// gates cache reuse.
3296    registry: Arc<WorktreesRegistry>,
3297    /// PR badges folded onto each worktree as the snapshot is built (#1337).
3298    /// Written by the background poller; read here. A miss simply omits `pr`.
3299    pr_cache: Arc<PrStatusCache>,
3300    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
3301    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
3302    /// coalesced build runs at most once per tick; tests inject a shorter value.
3303    ttl: Duration,
3304    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
3305    /// because it is deliberately held across the `.await` of the git
3306    /// enumeration, so concurrent callers serialize onto one build rather than
3307    /// each computing their own.
3308    state: AsyncMutex<Option<CachedTree>>,
3309    /// How many times the tree was actually (re)built — so tests can assert the
3310    /// coalescing collapses an N-stream burst into one build. Cheap and always
3311    /// maintained; only read under `#[cfg(test)]`.
3312    computes: AtomicU64,
3313}
3314
3315/// One cached tree snapshot: the shared value plus the two freshness stamps
3316/// [`TreeSnapshotCache`] checks before reusing it.
3317struct CachedTree {
3318    /// The registry change-generation captured *before* the build, so a change
3319    /// racing the build advances the generation and the next read rebuilds
3320    /// (conservative: it may rebuild once needlessly, but never serves stale).
3321    generation: u64,
3322    /// When the value was built, for the `ttl` staleness check.
3323    computed_at: Instant,
3324    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
3325    /// subscriber by cloning the `Arc`'s inner value.
3326    value: Arc<Value>,
3327}
3328
3329impl TreeSnapshotCache {
3330    /// Creates a cache over `registry` with the default TTL — the server's
3331    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
3332    /// build runs at most once per tick.
3333    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3334        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3335    }
3336
3337    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
3338    /// long) freshness window without waiting a real tick.
3339    fn with_ttl(
3340        registry: Arc<WorktreesRegistry>,
3341        pr_cache: Arc<PrStatusCache>,
3342        ttl: Duration,
3343    ) -> Self {
3344        Self {
3345            registry,
3346            pr_cache,
3347            ttl,
3348            state: AsyncMutex::new(None),
3349            computes: AtomicU64::new(0),
3350        }
3351    }
3352
3353    /// The current tree snapshot, built at most once per `ttl` per registry
3354    /// change-generation and shared across all callers. See the type docs for
3355    /// the freshness and single-flight semantics.
3356    async fn snapshot(&self) -> Value {
3357        // Hold the lock across the whole check-and-build so concurrent callers
3358        // serialize onto one build (single-flight); reading the generation here
3359        // (before the build) means a change racing the build forces the *next*
3360        // read to rebuild rather than serving this now-stale value.
3361        let mut state = self.state.lock().await;
3362        let generation = self.registry.change_generation();
3363        // Reuse the cached value only while it matches the current generation
3364        // *and* is within the TTL; either failing forces a rebuild.
3365        let fresh = state.as_ref().and_then(|cached| {
3366            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3367                .then(|| Arc::clone(&cached.value))
3368        });
3369        let value = if let Some(value) = fresh {
3370            value
3371        } else {
3372            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3373            self.computes.fetch_add(1, Ordering::Relaxed);
3374            *state = Some(CachedTree {
3375                generation,
3376                computed_at: Instant::now(),
3377                value: Arc::clone(&value),
3378            });
3379            value
3380        };
3381        // Release the lock before the (deeper) clone of the shared value out.
3382        drop(state);
3383        (*value).clone()
3384    }
3385
3386    /// How many times the tree was actually built — the coalescing assertion in
3387    /// tests (N reads within one tick/generation should build once).
3388    #[cfg(test)]
3389    fn compute_count(&self) -> u64 {
3390        self.computes.load(Ordering::Relaxed)
3391    }
3392}
3393
3394/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
3395/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
3396/// (the seed folders to derive repos from, and the live windows to join on) and
3397/// a lock-free read of the toggle, then the git enumeration/enrichment off the
3398/// lock on a blocking thread inside [`tree_repos`].
3399async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3400    let folders = registry.open_folders();
3401    let windows = registry.list();
3402    let show_closed = registry.show_closed();
3403    let enabled_polling = registry.enabled_polling_repos();
3404    // The transient rebase (#1415) and push (#1443) cues, read here with the other
3405    // cheap registry locks so the git work below deals only in plain data.
3406    let in_flight = InFlight::read(registry);
3407    json!({
3408        "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3409        "show_closed": show_closed,
3410    })
3411}
3412
3413/// A short human name for a window: its repo, else its first folder's basename,
3414/// else a placeholder.
3415fn display_name(entry: &WindowEntry) -> String {
3416    if let Some(repo) = &entry.repo {
3417        return repo.clone();
3418    }
3419    if let Some(folder) = entry.folders.first() {
3420        return folder.file_name().map_or_else(
3421            || folder.display().to_string(),
3422            |n| n.to_string_lossy().into_owned(),
3423        );
3424    }
3425    "(no folder)".to_string()
3426}
3427
3428/// Separator between the repo name and branch for a normal working tree.
3429const REPO_SEP: char = '·';
3430/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
3431/// line is distinguishable at a glance from its parent repo's main checkout.
3432const WORKTREE_SEP: char = '⑂';
3433
3434/// The full tray item list for a window set: the "No open windows" placeholder
3435/// when empty, else one line per window via [`window_menu_items`]. Does the git
3436/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
3437/// background refresh task — and inline only as a cold-start fallback in `menu`.
3438fn menu_items_for(
3439    entries: &[WindowEntry],
3440    rate_limit: Option<&RateLimitSnapshot>,
3441) -> Vec<MenuItem> {
3442    let mut items = Vec::new();
3443    // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
3444    // above the windows, so an approaching exhaustion is visible in the tray before
3445    // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
3446    if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3447        if !label.is_empty() {
3448            items.push(MenuItem::Label(label));
3449            items.push(MenuItem::Separator);
3450        }
3451    }
3452    if entries.is_empty() {
3453        items.push(MenuItem::Label("No open windows".to_string()));
3454    } else {
3455        items.extend(window_menu_items(entries));
3456    }
3457    items
3458}
3459
3460/// Builds the tray items for a non-empty window list: **one clickable line per
3461/// window** whose label carries the live git state and whose click focuses that
3462/// window. A window with no workspace folder has nothing for `code` to open, so
3463/// it stays a non-clickable status line. The labels read each worktree from disk
3464/// (via [`window_label`]) — cheap for a realistic window count and consistent
3465/// with reap-on-read.
3466fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3467    entries
3468        .iter()
3469        .map(|entry| {
3470            let label = window_label(entry);
3471            if entry.folders.is_empty() {
3472                MenuItem::Label(label)
3473            } else {
3474                MenuItem::Action(MenuAction {
3475                    id: format!("focus:{}", entry.key),
3476                    label,
3477                    enabled: true,
3478                })
3479            }
3480        })
3481        .collect()
3482}
3483
3484/// The tray label for one window: the **main repository** name, then live branch
3485/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
3486/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
3487/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
3488/// that is not a repo falls back to its reported title.
3489fn window_label(entry: &WindowEntry) -> String {
3490    let status = entry
3491        .folders
3492        .first()
3493        .map(|folder| git_status(folder))
3494        .unwrap_or_default();
3495    // Prefer the git-derived main repo so a linked worktree names its parent
3496    // repository rather than its worktree-folder basename.
3497    let name = status
3498        .main_repo
3499        .clone()
3500        .unwrap_or_else(|| display_name(entry));
3501    if let Some(branch) = &status.branch {
3502        let sep = if status.is_worktree {
3503            WORKTREE_SEP
3504        } else {
3505            REPO_SEP
3506        };
3507        return match sync_indicator(status.ahead, status.behind) {
3508            Some(sync) => format!("{name} {sep} {branch} {sync}"),
3509            None => format!("{name} {sep} {branch}"),
3510        };
3511    }
3512    // No git branch (not a repo / detached): fall back to the reported title.
3513    match &entry.title {
3514        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3515        _ => name,
3516    }
3517}
3518
3519/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
3520/// has no upstream to compare against.
3521fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3522    match (ahead, behind) {
3523        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3524        _ => None,
3525    }
3526}
3527
3528/// Well-known absolute locations for the VS Code launcher, tried in order so a
3529/// daemon running under launchd (with a minimal `PATH`) still finds it.
3530const CODE_BINARY_CANDIDATES: &[&str] = &[
3531    "/usr/local/bin/code",
3532    "/opt/homebrew/bin/code",
3533    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3534    "/usr/bin/code",
3535];
3536
3537/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
3538/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
3539/// with the sessions service's tray "focus" action, which resolves a session to
3540/// its VS Code window folder and opens it through this same guarded launcher.
3541pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3542    focus_window_with(&resolve_code_binary(), folder)
3543}
3544
3545/// Spawns `program` on `folder` after validating the folder. Split out from
3546/// [`focus_window`] so the validation and spawn paths are testable with an
3547/// explicit launcher (no environment or installed-editor dependency).
3548///
3549/// Best-effort and non-blocking: the spawned child is reaped on a detached
3550/// thread so a long-lived daemon does not accumulate zombies one per focus.
3551fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3552    // The tray path passes an absolute workspace folder, but the socket `open`
3553    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
3554    // real check there, not just an assertion: requiring an absolute path also
3555    // rules out a `-`-leading path being parsed by `code` as a flag.
3556    if !folder.is_absolute() {
3557        bail!(
3558            "refusing to focus a non-absolute folder path: {}",
3559            folder.display()
3560        );
3561    }
3562    if !folder.is_dir() {
3563        bail!("worktree folder no longer exists: {}", folder.display());
3564    }
3565    // Detach the launcher's stdio so its output never interleaves into the
3566    // long-lived daemon's own stdout/stderr (or the test harness's).
3567    let child = Command::new(program)
3568        .arg(folder)
3569        .stdin(Stdio::null())
3570        .stdout(Stdio::null())
3571        .stderr(Stdio::null())
3572        .spawn()
3573        .with_context(|| {
3574            format!(
3575                "failed to launch `{}` to focus {}",
3576                program.display(),
3577                folder.display()
3578            )
3579        })?;
3580    // Reap the child without blocking so it never lingers as a zombie.
3581    std::thread::spawn(move || {
3582        let mut child = child;
3583        let _ = child.wait();
3584    });
3585    Ok(())
3586}
3587
3588/// Resolves the VS Code launcher from the real environment: the
3589/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
3590/// `code` on `PATH`. The pure resolution logic lives in
3591/// [`resolve_code_binary_from`] for testing.
3592fn resolve_code_binary() -> PathBuf {
3593    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3594}
3595
3596/// Pure launcher resolution: `env_override` wins; otherwise the first existing
3597/// `candidate`; otherwise bare `code`.
3598fn resolve_code_binary_from(
3599    env_override: Option<std::ffi::OsString>,
3600    candidates: &[&str],
3601) -> PathBuf {
3602    if let Some(path) = env_override {
3603        return PathBuf::from(path);
3604    }
3605    for candidate in candidates {
3606        let path = Path::new(candidate);
3607        if path.exists() {
3608            return path.to_path_buf();
3609        }
3610    }
3611    PathBuf::from("code")
3612}
3613
3614// --- Reposition op (#1407) ---------------------------------------------------
3615
3616/// The `reposition` op payload: move every target window onto the invoking
3617/// window's geometry.
3618///
3619/// Keyed by **window key**, not worktree path, because the subject is a *window*:
3620/// the geometry belongs to the OS window, and a path resolves to one only via the
3621/// registry. The CLI (which naturally speaks paths) maps them to keys itself
3622/// before sending.
3623#[derive(Debug, Clone, Deserialize)]
3624struct RepositionRequest {
3625    /// The invoking window's key. Supplies the frame; never moved itself.
3626    reference_key: String,
3627    /// The windows to move. May include `reference_key` — a multi-selection
3628    /// naturally contains the invoking window — which is reported and skipped.
3629    #[serde(default)]
3630    target_keys: Vec<String>,
3631    /// Resolve and report only, writing nothing (`worktrees reposition
3632    /// --dry-run`). The diagnostic surface for title matching.
3633    #[serde(default)]
3634    check: bool,
3635}
3636
3637/// Distils the live registration for `key` into what [`geometry`] matches on.
3638///
3639/// A key with no live window still yields a value, flagged `live: false`, so it
3640/// reports as a per-target `no-window` skip rather than vanishing from the batch —
3641/// a tree row can be a tick stale, and the user needs to see which of their
3642/// selection was ignored.
3643fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3644    entries.iter().find(|entry| entry.key == key).map_or_else(
3645        || geometry::RegisteredWindow {
3646            key: key.to_string(),
3647            live: false,
3648            title: None,
3649            pid: None,
3650        },
3651        |entry| geometry::RegisteredWindow {
3652            key: entry.key.clone(),
3653            live: true,
3654            title: entry.title.clone(),
3655            pid: entry.pid,
3656        },
3657    )
3658}
3659
3660/// Renders a [`geometry::RepositionReport`] as the op's reply.
3661///
3662/// `trusted` is a reply **field**, not an error, so the client can branch on the
3663/// missing-permission case as data — offering the user a link to the Accessibility
3664/// settings pane — rather than pattern-matching an error string.
3665fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3666    let mut reply = json!({
3667        "trusted": report.trusted,
3668        "results": report.results,
3669        "moved": report.moved(),
3670        "skipped": report.skipped(),
3671    });
3672    if let Some(reference) = &report.reference {
3673        reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3674    }
3675    if let Some(blocked) = &report.blocked {
3676        reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3677    }
3678    // Omitted unless true, so a client that only reads `results` sees a reply
3679    // byte-identical to one from a daemon without the undo store.
3680    if undoable {
3681        reply["undoable"] = Value::Bool(true);
3682    }
3683    reply
3684}
3685
3686/// Emits the audit line for a `reposition`, so `omni-dev daemon logs` can answer
3687/// "why did that window not move?" from the log alone (the ADR-0049 §6 precedent).
3688/// Sync, like [`log_merge_check`].
3689fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3690    // `phase` is a structured field rather than three message literals, so a log
3691    // filter can select checks from applies without matching on prose.
3692    let phase = if !report.trusted {
3693        "untrusted"
3694    } else if report.blocked.is_some() {
3695        "blocked"
3696    } else if req.check {
3697        "check"
3698    } else {
3699        "apply"
3700    };
3701    tracing::info!(
3702        phase,
3703        reference = req.reference_key.as_str(),
3704        requested = req.target_keys.len(),
3705        blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3706        moved = report.moved(),
3707        skipped = report.skipped(),
3708        outcomes = outcome_kinds(report).as_str(),
3709        "reposition"
3710    );
3711}
3712
3713/// Emits the audit line for a `reposition-undo`.
3714fn log_reposition_undo(report: &geometry::RepositionReport) {
3715    tracing::info!(
3716        trusted = report.trusted,
3717        restored = report.moved(),
3718        skipped = report.skipped(),
3719        outcomes = outcome_kinds(report).as_str(),
3720        "reposition undo"
3721    );
3722}
3723
3724/// Joins a report's per-target outcome slugs into one compact `a,b,b` field, so a
3725/// batch's verdict rides a single structured log value rather than a `Debug` dump —
3726/// the [`note_kinds`] precedent.
3727fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3728    if report.results.is_empty() {
3729        return "-".to_string();
3730    }
3731    report
3732        .results
3733        .iter()
3734        .map(|r| r.outcome)
3735        .collect::<Vec<_>>()
3736        .join(",")
3737}
3738
3739// --- Reload op (#1417) -------------------------------------------------------
3740
3741/// The `reload` op payload: reload the listed windows.
3742///
3743/// Keyed by **window**, like [`RepositionRequest`] and unlike [`CloseRequest`] —
3744/// a reload acts on a window, and one tree row is one window, whereas a path can
3745/// be open in several. There is no `requester_key`: a client that wants to
3746/// reload itself does so directly rather than waiting a heartbeat for its own
3747/// directive, so the daemon never needs to know who asked.
3748#[derive(Debug, Clone, Deserialize)]
3749struct ReloadRequest {
3750    /// Registry keys of the windows to signal. An empty list is a no-op, not an
3751    /// error: the callers all filter their targets first, and reporting zeros is
3752    /// more useful to a batch client than a failure.
3753    #[serde(default)]
3754    target_keys: Vec<String>,
3755}
3756
3757/// Emits the audit line for a `reload` op. Sync, like the `close` loggers, so it
3758/// is unit-testable off the runtime. Logs counts and the unknown keys only —
3759/// never a path, which this op never sees.
3760fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3761    // Formatted before the macro, not inside it: a `tracing` field expression is
3762    // only evaluated when a subscriber is interested, so inlining this would
3763    // leave it unexecuted (and unmeasurable) in any test that installs none.
3764    let unknown = if unknown.is_empty() {
3765        "-".to_string()
3766    } else {
3767        unknown.join(",")
3768    };
3769    tracing::info!(
3770        requested,
3771        signalled,
3772        unknown = %unknown,
3773        "worktrees reload: signalled windows"
3774    );
3775}
3776
3777// --- Close op (#1277) --------------------------------------------------------
3778
3779/// The `close` op payload: close a worktree's window and (for a linked worktree)
3780/// delete it. Symmetric to `open`, but destructive, so it carries the
3781/// two-phase-confirm and self-close routing fields.
3782#[derive(Debug, Clone, Deserialize)]
3783struct CloseRequest {
3784    /// Absolute path of the target worktree's working directory.
3785    path: PathBuf,
3786    /// The requesting window's key, so a self-close (`requester_key` owns the
3787    /// target) removes-then-replies and lets the extension close its own window,
3788    /// rather than waiting on a window that is blocked awaiting this reply.
3789    #[serde(default)]
3790    requester_key: Option<String>,
3791    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
3792    /// only close its window (main "Close Window"). A delete is refused on the
3793    /// main working tree regardless of this flag.
3794    #[serde(default)]
3795    remove: bool,
3796    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
3797    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
3798    #[serde(default)]
3799    confirmed: bool,
3800}
3801
3802/// One risk or informational note in a [`SafetyReport`]: a machine-readable
3803/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
3804/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
3805/// commits that survive because the branch is kept).
3806#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3807struct Note {
3808    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
3809    kind: String,
3810    /// A human-readable one-line explanation for the confirm dialog.
3811    detail: String,
3812}
3813
3814impl Note {
3815    fn new(kind: &str, detail: impl Into<String>) -> Self {
3816        Self {
3817            kind: kind.to_string(),
3818            detail: detail.into(),
3819        }
3820    }
3821}
3822
3823/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
3824/// `-`) for a single structured log field. Used by the `close` op's audit lines
3825/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
3826fn note_kinds(notes: &[Note]) -> String {
3827    if notes.is_empty() {
3828        return "-".to_string();
3829    }
3830    notes
3831        .iter()
3832        .map(|n| n.kind.as_str())
3833        .collect::<Vec<_>>()
3834        .join(",")
3835}
3836
3837/// Whether a `close` execute is a **self-close**: the requesting window owns the
3838/// target, so it acts on our `ok:true` reply and never rides the cross-window
3839/// signal. Split out as a pure predicate so the routing decision the audit line
3840/// (#1364) reports is unit-testable.
3841fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3842    requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3843}
3844
3845/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
3846/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
3847/// this a failed safety check (a non-git-worktree target) or a panicked blocking
3848/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
3849/// the error unchanged so callers keep using `?`.
3850fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3851    tracing::error!(
3852        path = %path.display(),
3853        "worktrees close: {phase} failed: {err:#}"
3854    );
3855    err
3856}
3857
3858/// Logs the outcome of a linked-worktree removal and maps it to the `close`
3859/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
3860/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
3861/// the real prune runs behind.
3862///
3863/// The three outcomes are logged distinctly (#1403) so a future "the daemon says
3864/// pruned but the row is still there" is diagnosable from the log alone: an
3865/// actual prune and an already-gone no-op are both INFO (and both reply
3866/// `removed: true` — the row should go either way), but carry different
3867/// `outcome`/message text; a failure is WARN and propagates the error.
3868fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3869    match removed {
3870        Ok(Removal::Pruned) => {
3871            tracing::info!(
3872                path = %path.display(),
3873                outcome = "pruned",
3874                "worktrees close: linked worktree pruned"
3875            );
3876            Ok(json!({ "removed": true }))
3877        }
3878        Ok(Removal::AlreadyGone) => {
3879            tracing::info!(
3880                path = %path.display(),
3881                outcome = "already-gone",
3882                "worktrees close: nothing to prune, worktree already removed"
3883            );
3884            Ok(json!({ "removed": true }))
3885        }
3886        Err(err) => {
3887            tracing::warn!(
3888                path = %path.display(),
3889                outcome = "failed",
3890                "worktrees close: worktree prune failed: {err:#}"
3891            );
3892            Err(err)
3893        }
3894    }
3895}
3896
3897/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
3898/// the owning window key (if any), the open flag, and the deletability verdict
3899/// with the blocking risk kinds. Split out so the audit line is unit-testable off
3900/// the runtime — a `tracing` event fired right after the `git_safety`
3901/// `spawn_blocking` is not reliably captured under the parallel suite.
3902fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3903    tracing::info!(
3904        path = %path.display(),
3905        window_key = window_key.unwrap_or("-"),
3906        removable = git.removable,
3907        is_main = git.is_main,
3908        open,
3909        risks = %note_kinds(&git.risks),
3910        "worktrees close: safety check"
3911    );
3912}
3913
3914/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
3915/// window key and the routing decision (self-close vs. how many cross-window
3916/// targets are being signalled), logged before the wait so it is auditable even
3917/// if that wait then hangs. Sync so it is unit-testable off the runtime.
3918fn log_executing(
3919    path: &Path,
3920    requester: Option<&str>,
3921    remove: bool,
3922    self_close: bool,
3923    cross_window: usize,
3924) {
3925    tracing::info!(
3926        path = %path.display(),
3927        requester = requester.unwrap_or("-"),
3928        remove,
3929        self_close,
3930        cross_window,
3931        "worktrees close: executing"
3932    );
3933}
3934
3935/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
3936/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
3937/// unit-testable off the runtime.
3938fn log_close_abort(path: &Path, err: &anyhow::Error) {
3939    tracing::warn!(
3940        path = %path.display(),
3941        "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3942    );
3943}
3944
3945/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
3946/// the window is closed and nothing is deleted (#1364). Sync so it is
3947/// unit-testable off the runtime.
3948fn log_window_closed(path: &Path) {
3949    tracing::info!(
3950        path = %path.display(),
3951        "worktrees close: window closed, no removal"
3952    );
3953}
3954
3955/// The phase-1 safety report the extension reads to decide whether to prompt.
3956/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
3957/// entry → show a modal confirm listing them.
3958#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3959struct SafetyReport {
3960    /// Whether the target is a deletable (linked) worktree at all — `false` for
3961    /// the main working tree, which the daemon never removes.
3962    removable: bool,
3963    /// Whether the target is the repository's main working tree.
3964    is_main: bool,
3965    /// Whether a live VS Code window currently has the target open.
3966    open: bool,
3967    /// The owning window's key, when `open` (the first, for the wait/close).
3968    #[serde(skip_serializing_if = "Option::is_none")]
3969    window_key: Option<String>,
3970    /// How many workspace folders the owning window has — so the extension can
3971    /// warn "this window has N folders open; all will close" (failure mode #10).
3972    window_folder_count: usize,
3973    /// Conditions that would lose data on removal; a non-empty list forces a
3974    /// confirm dialog.
3975    risks: Vec<Note>,
3976    /// Non-blocking context shown for awareness (e.g. unpushed commits that
3977    /// survive because the branch is kept).
3978    info: Vec<Note>,
3979}
3980
3981/// The git-only half of the safety check, before the registry's open-window
3982/// facts are folded in. Pure disk I/O; computed on a blocking thread.
3983#[derive(Debug, Clone, PartialEq, Eq)]
3984struct GitSafety {
3985    is_main: bool,
3986    removable: bool,
3987    risks: Vec<Note>,
3988    info: Vec<Note>,
3989}
3990
3991// --- Rebase op (#1415) -------------------------------------------------------
3992
3993/// The `rebase` op payload: batch-rebase worktrees onto their repository's remote
3994/// default branch. Two-phase like [`MergeQueueRequest`], keyed off `confirmed`,
3995/// and likewise a **single batched** op over `paths` — which is what buys the
3996/// fetch-once-per-repository contract (ADR-0055 §2), since the engine can only
3997/// group by repository if it sees the whole selection at once.
3998#[derive(Debug, Clone, Deserialize)]
3999struct RebaseRequest {
4000    /// Absolute paths of the selected worktree folders.
4001    paths: Vec<PathBuf>,
4002    /// The requesting window's key — carried for the audit line, as `close` and
4003    /// `merge-queue` carry theirs.
4004    #[serde(default)]
4005    requester_key: Option<String>,
4006    /// Phase 1: plan and report only, never rebase.
4007    #[serde(default)]
4008    check: bool,
4009    /// Phase 2: rebase the (re-validated) pending worktrees.
4010    #[serde(default)]
4011    confirmed: bool,
4012    /// Leave a conflicting worktree mid-rebase instead of aborting it. The tree
4013    /// view sends `true` — resolving a conflict in place is the point of #1415 —
4014    /// but it stays a client choice, and defaults to the engine's conservative
4015    /// abort so an older or scripted client gets the pre-#1415 behaviour.
4016    #[serde(default)]
4017    keep_conflicts: bool,
4018    /// Stash uncommitted changes around each rebase rather than skipping a dirty
4019    /// worktree. Not surfaced by the tree view; here so a socket client can ask.
4020    #[serde(default)]
4021    autostash: bool,
4022    /// Rebase onto this ref instead of the remote default branch.
4023    #[serde(default)]
4024    onto: Option<String>,
4025}
4026
4027impl RebaseRequest {
4028    /// The engine options this request selects, with `git` already resolved.
4029    fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4030        worktree_rebase::RebaseOptions {
4031            onto: self.onto.clone(),
4032            autostash: self.autostash,
4033            // The daemon never uses the engine's own dry-run flag: phase 1 *is*
4034            // the dry run, and it is `plan` (never `execute`) that runs for it.
4035            dry_run: false,
4036            keep_conflicts: self.keep_conflicts,
4037            git_bin: Some(git_bin),
4038        }
4039    }
4040}
4041
4042/// Runs [`worktree_rebase::plan`] on a blocking thread: it shells out to
4043/// `git fetch` once per repository and walks each worktree's object database, so
4044/// it must never run on an async worker.
4045async fn plan_rebase(
4046    selection: &Selection,
4047    opts: &worktree_rebase::RebaseOptions,
4048) -> Result<worktree_rebase::Plan> {
4049    let selection = selection.clone();
4050    let opts = opts.clone();
4051    tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4052        .await
4053        .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4054        .and_then(|inner| inner)
4055}
4056
4057/// Builds a `rebase` reply. Both phases share one shape — the per-repo fetch
4058/// outcomes and the per-worktree results — because phase 1's report and phase 2's
4059/// result differ only in which [`RebaseResult`](worktree_rebase::RebaseResult)
4060/// variants appear, and a client that can render one can render the other.
4061fn rebase_reply(
4062    fetches: &[worktree_rebase::FetchOutcome],
4063    worktrees: &[worktree_rebase::WorktreeOutcome],
4064) -> Value {
4065    json!({ "fetches": fetches, "worktrees": worktrees })
4066}
4067
4068/// Emits the phase-1 audit line for a `rebase` plan (ADR-0049 §6's precedent, as
4069/// applied by [`log_merge_check`]): who asked, how many worktrees were named, and
4070/// how many the classifier found actually pending.
4071fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4072    let pending = plan
4073        .worktrees
4074        .iter()
4075        .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4076        .count();
4077    let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4078    tracing::info!(
4079        requester = req.requester_key.as_deref().unwrap_or("-"),
4080        requested = req.paths.len(),
4081        pending,
4082        fetches = plan.fetches.len(),
4083        failed_fetches,
4084        "rebase check"
4085    );
4086}
4087
4088/// Emits the phase-2 audit line for a `rebase` execute: the history-rewriting
4089/// outcome, counted by kind. A left-in-place conflict is counted separately from
4090/// an aborted one — it is the case that leaves a worktree needing the user.
4091fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4092    use worktree_rebase::RebaseResult;
4093    let mut rebased = 0;
4094    let mut conflicts = 0;
4095    let mut left_in_place = 0;
4096    let mut skipped = 0;
4097    for outcome in outcomes {
4098        match &outcome.result {
4099            RebaseResult::Rebased { .. } => rebased += 1,
4100            RebaseResult::Conflict {
4101                left_in_place: k, ..
4102            } => {
4103                conflicts += 1;
4104                if *k {
4105                    left_in_place += 1;
4106                }
4107            }
4108            RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4109            RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4110        }
4111    }
4112    tracing::info!(
4113        requester = req.requester_key.as_deref().unwrap_or("-"),
4114        requested = req.paths.len(),
4115        rebased,
4116        conflicts,
4117        left_in_place,
4118        skipped,
4119        "rebase execute"
4120    );
4121}
4122
4123// --- Push op (#1443) ---------------------------------------------------------
4124
4125/// The `push` op payload: publish worktrees' branches to their upstreams,
4126/// force-pushing with a lease where history was rewritten. Two-phase like
4127/// [`RebaseRequest`], keyed off `confirmed`, and likewise a **single batched** op
4128/// over `paths` so the reply is one per-worktree summary rather than N independent
4129/// results.
4130///
4131/// Deliberately has **no** force knob. There is no field a client can set to escape
4132/// the lease, and none to reach a remote other than the branch's own upstream —
4133/// both by design (ADR-0061 §2).
4134#[derive(Debug, Clone, Deserialize)]
4135struct PushRequest {
4136    /// Absolute paths of the selected worktree folders.
4137    paths: Vec<PathBuf>,
4138    /// The requesting window's key — carried for the audit line, as `close`,
4139    /// `merge-queue` and `rebase` carry theirs.
4140    #[serde(default)]
4141    requester_key: Option<String>,
4142    /// Phase 1: classify and report only, never push.
4143    #[serde(default)]
4144    check: bool,
4145    /// Phase 2: publish the (re-validated) pending worktrees.
4146    #[serde(default)]
4147    confirmed: bool,
4148}
4149
4150/// Runs [`worktree_push::plan`] on a blocking thread: it walks each worktree's
4151/// object database, so it must never run on an async worker. Unlike
4152/// [`plan_rebase`] it needs no `git` binary — planning a push contacts no remote.
4153async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4154    let selection = selection.clone();
4155    tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4156        .await
4157        .map_err(|e| anyhow!("push planning task panicked: {e}"))
4158        .and_then(|inner| inner)
4159}
4160
4161/// Builds a `push` reply. Both phases share one shape — the per-worktree results —
4162/// because phase 1's report and phase 2's result differ only in which
4163/// [`PushResult`](worktree_push::PushResult) variants appear, and a client that can
4164/// render one can render the other.
4165///
4166/// There is no `fetches` field (the one shape difference from `rebase`): a push
4167/// plan contacts no remote, so there is nothing per-repository to report.
4168fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4169    json!({ "worktrees": worktrees })
4170}
4171
4172/// Emits the phase-1 audit line for a `push` plan: who asked, how many worktrees
4173/// were named, how many are pending, and — separately — how many would need the
4174/// lease, since that is the interesting half.
4175fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4176    use worktree_push::PushResult;
4177    let pending = plan
4178        .worktrees
4179        .iter()
4180        .filter(|w| w.result.is_pending())
4181        .count();
4182    let forced = plan
4183        .worktrees
4184        .iter()
4185        .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4186        .count();
4187    let skipped = plan
4188        .worktrees
4189        .iter()
4190        .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4191        .count();
4192    tracing::info!(
4193        requester = req.requester_key.as_deref().unwrap_or("-"),
4194        requested = req.paths.len(),
4195        pending,
4196        forced,
4197        skipped,
4198        "push check"
4199    );
4200}
4201
4202/// Emits the phase-2 audit line for a `push` execute. `forced` and `stale_rejected`
4203/// are broken out deliberately: the first is the count of histories this daemon
4204/// published a rewrite of, and the second the count of times the lease stopped it
4205/// from overwriting work it had not seen.
4206fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4207    use worktree_push::PushResult;
4208    let mut pushed = 0;
4209    let mut forced = 0;
4210    let mut created = 0;
4211    let mut rejected = 0;
4212    let mut stale_rejected = 0;
4213    for outcome in outcomes {
4214        match &outcome.result {
4215            PushResult::Pushed { forced: f } => {
4216                pushed += 1;
4217                if *f {
4218                    forced += 1;
4219                }
4220            }
4221            PushResult::Created => created += 1,
4222            PushResult::Rejected { stale, .. } => {
4223                rejected += 1;
4224                if *stale {
4225                    stale_rejected += 1;
4226                }
4227            }
4228            PushResult::UpToDate
4229            | PushResult::WouldFastForward { .. }
4230            | PushResult::WouldForce { .. }
4231            | PushResult::WouldCreate
4232            | PushResult::Skipped { .. } => {}
4233        }
4234    }
4235    tracing::info!(
4236        requester = req.requester_key.as_deref().unwrap_or("-"),
4237        requested = req.paths.len(),
4238        pushed,
4239        forced,
4240        created,
4241        rejected,
4242        stale_rejected,
4243        "push execute"
4244    );
4245}
4246
4247// --- Merge-queue op (#1401) --------------------------------------------------
4248
4249/// The `merge-queue` op payload: batch-enqueue eligible worktrees' PRs into the
4250/// GitHub merge queue. Two-phase like [`CloseRequest`], keyed off `confirmed`, but
4251/// a **single batched** op over `paths` rather than one op per target.
4252#[derive(Debug, Clone, Deserialize)]
4253struct MergeQueueRequest {
4254    /// Absolute paths of the selected worktree folders.
4255    paths: Vec<PathBuf>,
4256    /// The requesting window's key — carried for parity with `close` and future
4257    /// per-window routing; unused today.
4258    #[serde(default)]
4259    requester_key: Option<String>,
4260    /// Phase 1: report eligibility only, never enqueue.
4261    #[serde(default)]
4262    check: bool,
4263    /// Phase 2: enqueue the (re-validated) eligible PRs.
4264    #[serde(default)]
4265    confirmed: bool,
4266}
4267
4268/// One enqueue-eligible worktree in an [`EligibilityReport`].
4269#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4270struct PrRef {
4271    /// The worktree folder.
4272    path: String,
4273    /// The open PR number.
4274    number: u64,
4275    /// The PR's web URL.
4276    url: String,
4277    /// The branch the PR heads.
4278    branch: String,
4279}
4280
4281impl From<&Eligible> for PrRef {
4282    fn from(e: &Eligible) -> Self {
4283        Self {
4284            path: e.path.to_string_lossy().to_string(),
4285            number: e.number,
4286            url: e.url.clone(),
4287            branch: e.branch.clone(),
4288        }
4289    }
4290}
4291
4292/// One skipped worktree: which, and why — a machine `kind` slug plus a
4293/// human-readable `detail`, mirroring [`Note`].
4294#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4295struct Skip {
4296    path: String,
4297    kind: String,
4298    detail: String,
4299}
4300
4301impl Skip {
4302    fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4303        Self {
4304            path: path.to_string_lossy().to_string(),
4305            kind: kind.to_string(),
4306            detail: detail.into(),
4307        }
4308    }
4309}
4310
4311/// The phase-1 reply: which selected worktrees are enqueue-eligible and which are
4312/// skipped-with-reason. The extension confirms once over the whole set, then sends
4313/// the phase-2 execute.
4314#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4315struct EligibilityReport {
4316    eligible: Vec<PrRef>,
4317    skipped: Vec<Skip>,
4318}
4319
4320/// One PR successfully in the queue after phase 2.
4321#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4322struct QueuedPr {
4323    path: String,
4324    number: u64,
4325    /// True when the PR was already in the queue — an idempotent no-op, reported as
4326    /// success. Omitted (false) on the wire for the common freshly-queued case.
4327    #[serde(skip_serializing_if = "is_false")]
4328    already_queued: bool,
4329}
4330
4331/// One PR the enqueue mutation rejected (merge queue disabled, not mergeable,
4332/// insufficient permissions, …).
4333#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4334struct EnqueueFailure {
4335    path: String,
4336    number: u64,
4337    error: String,
4338}
4339
4340/// The phase-2 reply: the enqueue outcome for the selected worktrees. `skipped` is
4341/// the re-validated skip set (a worktree that became ineligible between phases).
4342#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4343struct EnqueueResult {
4344    queued: Vec<QueuedPr>,
4345    skipped: Vec<Skip>,
4346    failed: Vec<EnqueueFailure>,
4347}
4348
4349/// A worktree that cleared the local (git-only) gates 1–3, carrying what the
4350/// network step needs to resolve its PR.
4351#[derive(Debug)]
4352struct LocalOk {
4353    path: PathBuf,
4354    target: PrTarget,
4355    head_sha: String,
4356}
4357
4358/// A worktree that cleared **every** gate and is ready to enqueue.
4359#[derive(Debug)]
4360struct Eligible {
4361    path: PathBuf,
4362    number: u64,
4363    url: String,
4364    branch: String,
4365    /// The PR's GraphQL node id — the `enqueuePullRequest` input.
4366    pr_id: String,
4367    /// Already in the queue ⇒ phase 2 skips the mutation and reports success.
4368    already_queued: bool,
4369}
4370
4371/// Evaluates the **local** (git-only) merge-queue gates for one worktree — clean
4372/// tree (1), a real commit (2), fully pushed (3) — and resolves the branch's
4373/// [`PrTarget`] for the network step. Pure disk I/O; runs on a blocking thread.
4374/// Returns the first failing gate as a [`Skip`] so an ineligible worktree never
4375/// costs a GitHub call.
4376fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4377    let Ok(repo) = Repository::discover(path) else {
4378        return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4379    };
4380    // Gate 1: a clean working tree (reusing the `close` safety check's counter).
4381    let (dirty, untracked) = count_dirty_untracked(&repo);
4382    if dirty > 0 {
4383        return Err(Skip::new(
4384            path,
4385            "dirty",
4386            format!("{dirty} modified tracked file(s) — commit or stash first"),
4387        ));
4388    }
4389    if untracked > 0 {
4390        return Err(Skip::new(
4391            path,
4392            "untracked",
4393            format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4394        ));
4395    }
4396    // Gate 2: a real commit exists (a non-unborn HEAD). The deeper "commits beyond
4397    // base" is proven by the open PR (gate 4) + GitHub's own enqueue validation.
4398    let Ok(head) = repo.head() else {
4399        return Err(Skip::new(
4400            path,
4401            "no-commits",
4402            "the branch has no commits yet",
4403        ));
4404    };
4405    let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4406        return Err(Skip::new(
4407            path,
4408            "no-commits",
4409            "HEAD does not resolve to a commit",
4410        ));
4411    };
4412    // A branch HEAD has a UTF-8 shorthand; a detached HEAD has no branch — and so
4413    // no branch PR to enqueue. Read before `Branch::wrap` consumes `head`.
4414    let Some(branch_name) = head
4415        .shorthand()
4416        .ok()
4417        .filter(|_| head.is_branch())
4418        .map(str::to_string)
4419    else {
4420        return Err(Skip::new(
4421            path,
4422            "detached",
4423            "HEAD is detached — no branch to enqueue",
4424        ));
4425    };
4426    let branch = git2::Branch::wrap(head);
4427    // Gate 3: fully pushed — an upstream exists and matches the local head.
4428    let Some(upstream_sha) = upstream_target(&branch) else {
4429        return Err(Skip::new(
4430            path,
4431            "no-upstream",
4432            "the branch tracks no upstream — push it first",
4433        ));
4434    };
4435    if upstream_sha != head_sha {
4436        return Err(Skip::new(
4437            path,
4438            "unpushed",
4439            "local commits are not on the remote yet — push first",
4440        ));
4441    }
4442    // Belt-and-suspenders: even with matching heads, a positive ahead count is
4443    // unpushed work.
4444    if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4445        if ahead > 0 {
4446            return Err(Skip::new(
4447                path,
4448                "unpushed",
4449                format!("{ahead} unpushed commit(s) — push first"),
4450            ));
4451        }
4452    }
4453    // Gate 4 setup: the branch's GitHub identity, so the network step can resolve
4454    // its PR. A non-github repo can never have a merge-queue PR.
4455    let Some(id) = remote_github_identity(&repo) else {
4456        return Err(Skip::new(
4457            path,
4458            "no-github",
4459            "the repository has no github.com remote",
4460        ));
4461    };
4462    Ok(LocalOk {
4463        path: path.to_path_buf(),
4464        target: PrTarget {
4465            owner: id.owner,
4466            name: id.name,
4467            branch: branch_name,
4468        },
4469        head_sha,
4470    })
4471}
4472
4473/// Whether GitHub's `mergeStateStatus` says the PR cannot merge cleanly. `DIRTY`
4474/// (merge conflicts) and the explicit `CONFLICTING` both block enqueue; other
4475/// states (`BLOCKED` on a required review, `UNKNOWN` still computing, `CLEAN`) do
4476/// not, since the merge queue itself resolves them.
4477fn is_conflicting(state: Option<&str>) -> bool {
4478    matches!(state, Some("CONFLICTING" | "DIRTY"))
4479}
4480
4481/// A human-readable label for a rolled-up CI verdict, for a `checks-failing` skip.
4482fn check_label(state: PrCheckState) -> &'static str {
4483    match state {
4484        PrCheckState::Success => "passing",
4485        PrCheckState::Failure => "failing",
4486        PrCheckState::Pending => "still running",
4487        PrCheckState::None => "not reported",
4488    }
4489}
4490
4491/// Emits the phase-1 audit line for a `merge-queue` check (ADR-0056; the ADR-0049
4492/// §6 precedent): the requesting window key, how many worktrees were requested,
4493/// and the eligible/skipped split. Sync so it is unit-testable off the runtime —
4494/// and so its `tracing` field expressions are exercised under an INFO subscriber.
4495fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4496    tracing::info!(
4497        requester = req.requester_key.as_deref().unwrap_or("-"),
4498        requested = req.paths.len(),
4499        eligible,
4500        skipped,
4501        "merge-queue check"
4502    );
4503}
4504
4505/// Emits the phase-2 audit line for a `merge-queue` enqueue: the requesting window
4506/// key and the queued/failed/skipped counts. Sync, for the same reasons as
4507/// [`log_merge_check`].
4508fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4509    tracing::info!(
4510        requester = req.requester_key.as_deref().unwrap_or("-"),
4511        queued,
4512        failed,
4513        skipped,
4514        "merge-queue enqueue"
4515    );
4516}
4517
4518/// Evaluates every merge-queue gate for a batch of worktree paths and partitions
4519/// them into the enqueue-eligible and the skipped-with-reason. **Blocking** — run
4520/// on a blocking thread.
4521///
4522/// Local gates 1–3 run first (per path); only survivors reach GitHub, so a dirty
4523/// or unpushed worktree is skipped with **zero** API calls. The survivors' PRs are
4524/// resolved in **one** batched `gh api graphql` call, then the network gates —
4525/// an open PR (4), not a draft (5), not conflicting (6), CI green (7), and the
4526/// remote head matching the local head — are applied. Shared by both phases: phase
4527/// 2 re-runs it (never trusting a phase-1 result the client sent).
4528fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4529    let mut skipped = Vec::new();
4530    let mut locals = Vec::new();
4531    for path in paths {
4532        match evaluate_local(path) {
4533            Ok(ok) => locals.push(ok),
4534            Err(skip) => skipped.push(skip),
4535        }
4536    }
4537    if locals.is_empty() {
4538        return Ok((Vec::new(), skipped));
4539    }
4540    let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4541    let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4542    let mut eligible = Vec::new();
4543    for local in locals {
4544        let Some(info) = resolved.get(&local.target) else {
4545            skipped.push(Skip::new(
4546                &local.path,
4547                "no-pr",
4548                "no open PR heads this branch",
4549            ));
4550            continue;
4551        };
4552        if info.head_oid != local.head_sha {
4553            skipped.push(Skip::new(
4554                &local.path,
4555                "stale",
4556                "the open PR's head differs from the local head — re-check",
4557            ));
4558        } else if info.is_draft {
4559            skipped.push(Skip::new(
4560                &local.path,
4561                "draft",
4562                format!("PR #{} is a draft", info.number),
4563            ));
4564        } else if is_conflicting(info.merge_state.as_deref()) {
4565            skipped.push(Skip::new(
4566                &local.path,
4567                "conflicting",
4568                format!("PR #{} has merge conflicts", info.number),
4569            ));
4570        } else if info.checks != PrCheckState::Success {
4571            skipped.push(Skip::new(
4572                &local.path,
4573                "checks-failing",
4574                format!(
4575                    "PR #{} checks are {}",
4576                    info.number,
4577                    check_label(info.checks)
4578                ),
4579            ));
4580        } else {
4581            eligible.push(Eligible {
4582                path: local.path,
4583                number: info.number,
4584                url: info.url.clone(),
4585                branch: local.target.branch.clone(),
4586                pr_id: info.pr_id.clone(),
4587                already_queued: info.already_queued,
4588            });
4589        }
4590    }
4591    Ok((eligible, skipped))
4592}
4593
4594/// Enqueues each eligible PR into its repo's merge queue, sequentially.
4595/// **Blocking** — run on a blocking thread. An already-queued PR is reported as
4596/// success without a mutation; a GitHub rejection or a failed `gh` invocation
4597/// lands in `failed[]`, so one un-enqueuable PR never sinks the batch.
4598fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4599    let mut queued = Vec::new();
4600    let mut failed = Vec::new();
4601    for e in eligible {
4602        let path = e.path.to_string_lossy().to_string();
4603        if e.already_queued {
4604            queued.push(QueuedPr {
4605                path,
4606                number: e.number,
4607                already_queued: true,
4608            });
4609            continue;
4610        }
4611        match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4612            Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4613                path,
4614                number: e.number,
4615                already_queued: false,
4616            }),
4617            Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4618                path,
4619                number: e.number,
4620                error: msg,
4621            }),
4622            Err(err) => failed.push(EnqueueFailure {
4623                path,
4624                number: e.number,
4625                error: format!("{err:#}"),
4626            }),
4627        }
4628    }
4629    (queued, failed)
4630}
4631
4632/// Live windows (key, workspace-folder count) that currently have `path` open,
4633/// matched by canonicalized path so a symlinked or `..`-laden report still
4634/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
4635fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4636    let target = canonical(path);
4637    entries
4638        .iter()
4639        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4640        .map(|e| (e.key.clone(), e.folders.len()))
4641        .collect()
4642}
4643
4644/// How long the execute phase waits for a signalled window to close
4645/// (`unregister`) before giving up. Deliberately generous against the ~10s
4646/// heartbeat interval the close directive rides — a window may have just
4647/// heartbeated, so the directive is only picked up on the *next* one — plus the
4648/// window's own close/save latency. The keyed-push responsiveness upgrade
4649/// (#1277 fast-follow) removes this wait entirely.
4650const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4651
4652/// How often the execute phase re-checks whether the signalled windows have
4653/// unregistered.
4654const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4655
4656/// Waits up to `timeout` for every window *other than* `requester` that has
4657/// `path` open to unregister (close), polling the live registry every `poll`.
4658/// A window whose `last_seen` has already gone stale is reaped by `list()` and
4659/// so counts as closed. Returns an error naming the still-open windows on
4660/// timeout, so the caller can surface "window did not close" and leave the
4661/// worktree untouched (failure modes #4/#5).
4662async fn await_windows_closed(
4663    registry: &WorktreesRegistry,
4664    path: &Path,
4665    requester: Option<&str>,
4666    timeout: Duration,
4667    poll: Duration,
4668) -> Result<()> {
4669    let deadline = std::time::Instant::now() + timeout;
4670    loop {
4671        // The registry read is cheap CPU, but the path canonicalization in
4672        // `windows_with_path` is disk I/O — do the whole check on a blocking
4673        // thread, never on the async worker.
4674        let entries = registry.list();
4675        let path = path.to_path_buf();
4676        let requester = requester.map(str::to_string);
4677        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4678            windows_with_path(&entries, &path)
4679                .into_iter()
4680                .map(|(k, _)| k)
4681                .filter(|k| requester.as_deref() != Some(k))
4682                .collect()
4683        })
4684        .await
4685        .unwrap_or_default();
4686
4687        if remaining.is_empty() {
4688            return Ok(());
4689        }
4690        if std::time::Instant::now() >= deadline {
4691            bail!("window(s) did not close in time: {}", remaining.join(", "));
4692        }
4693        tokio::time::sleep(poll).await;
4694    }
4695}
4696
4697/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
4698/// working tree (never removable) and, for a linked worktree, what a removal
4699/// would lose. Best-effort per-check but the overall open must succeed — a path
4700/// that is not a git worktree is a hard error (we refuse to delete an unknown
4701/// directory). A path that no longer exists is treated as an already-removed
4702/// linked worktree so the idempotent execute path can proceed with no dialog.
4703fn git_safety(path: &Path) -> Result<GitSafety> {
4704    if !path.exists() {
4705        return Ok(GitSafety {
4706            is_main: false,
4707            removable: true,
4708            risks: vec![],
4709            info: vec![Note::new("already-removed", "worktree no longer exists")],
4710        });
4711    }
4712    let repo = Repository::open(path)
4713        .with_context(|| format!("not a git worktree: {}", path.display()))?;
4714    // The one structural fact deletability keys off — never the branch name.
4715    if !repo.is_worktree() {
4716        return Ok(GitSafety {
4717            is_main: true,
4718            removable: false,
4719            risks: vec![],
4720            info: vec![Note::new(
4721                "main-working-tree",
4722                "the repository's main working tree is never deleted",
4723            )],
4724        });
4725    }
4726
4727    let mut risks = Vec::new();
4728    let mut info = Vec::new();
4729
4730    let (dirty, untracked) = count_dirty_untracked(&repo);
4731    if dirty > 0 {
4732        risks.push(Note::new(
4733            "dirty",
4734            format!("{dirty} modified tracked file(s) would be lost"),
4735        ));
4736    }
4737    if untracked > 0 {
4738        risks.push(Note::new(
4739            "untracked",
4740            format!("{untracked} untracked file(s) would be lost"),
4741        ));
4742    }
4743
4744    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
4745    let state = repo.state();
4746    if state != RepositoryState::Clean {
4747        risks.push(Note::new(
4748            "in-progress",
4749            format!("an in-progress {state:?} operation would be lost"),
4750        ));
4751    }
4752
4753    // Commits reachable only from a detached HEAD are GC'd once the worktree —
4754    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
4755    // or tag) loses nothing, so it is not flagged.
4756    if repo.head_detached().unwrap_or(false) {
4757        let lost = unreachable_commit_count(&repo).unwrap_or(0);
4758        if lost > 0 {
4759            risks.push(Note::new(
4760                "unreachable-commits",
4761                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4762            ));
4763        }
4764    }
4765
4766    // Unpushed commits on a *named* branch survive: removal never deletes the
4767    // branch. Informational only — it must not block or prompt.
4768    if let Some(ahead) = current_branch_ahead(&repo) {
4769        if ahead > 0 {
4770            info.push(Note::new(
4771                "unpushed",
4772                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4773            ));
4774        }
4775    }
4776
4777    Ok(GitSafety {
4778        is_main: false,
4779        removable: true,
4780        risks,
4781        info,
4782    })
4783}
4784
4785/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
4786/// staged or unstaged modification (including conflicts and deletions);
4787/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
4788/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
4789/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
4790/// `(0, 0)` rather than sinking the whole safety check.
4791fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4792    let mut opts = StatusOptions::new();
4793    opts.include_untracked(true)
4794        .recurse_untracked_dirs(true)
4795        .include_ignored(false)
4796        .exclude_submodules(true);
4797    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4798        return (0, 0);
4799    };
4800    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
4801    // it is deliberately excluded from this mask).
4802    let tracked = Status::INDEX_NEW
4803        | Status::INDEX_MODIFIED
4804        | Status::INDEX_DELETED
4805        | Status::INDEX_RENAMED
4806        | Status::INDEX_TYPECHANGE
4807        | Status::WT_MODIFIED
4808        | Status::WT_DELETED
4809        | Status::WT_TYPECHANGE
4810        | Status::WT_RENAMED
4811        | Status::CONFLICTED;
4812    let mut dirty = 0;
4813    let mut untracked = 0;
4814    for entry in statuses.iter() {
4815        let s = entry.status();
4816        if s.contains(Status::WT_NEW) {
4817            untracked += 1;
4818        }
4819        if s.intersects(tracked) {
4820            dirty += 1;
4821        }
4822    }
4823    (dirty, untracked)
4824}
4825
4826/// Counts commits reachable from the (detached) HEAD but from no other ref —
4827/// the commits git would garbage-collect once the worktree's HEAD is gone.
4828/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
4829/// skipped (hiding it would hide the very commits we are counting); every real
4830/// branch/tag/remote ref is hidden, so a tip that any branch also points at
4831/// yields `0` (nothing is actually lost).
4832fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4833    let head_oid = repo.head().ok()?.target()?;
4834    let mut walk = repo.revwalk().ok()?;
4835    walk.push(head_oid).ok()?;
4836    for reference in repo.references().ok()? {
4837        let Ok(reference) = reference else { continue };
4838        // Skip the literal HEAD ref — hiding it would hide the very commits we
4839        // are counting; every real branch/tag/remote ref is hidden below.
4840        if matches!(reference.name(), Ok("HEAD")) {
4841            continue;
4842        }
4843        if let Some(oid) = reference.target() {
4844            let _ = walk.hide(oid);
4845        }
4846    }
4847    Some(walk.flatten().count())
4848}
4849
4850/// Commits the worktree's current branch is ahead of its upstream, or `None`
4851/// when HEAD is detached or the branch tracks no upstream. Reuses
4852/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
4853fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4854    let head = repo.head().ok()?;
4855    if !head.is_branch() {
4856        return None;
4857    }
4858    let branch = git2::Branch::wrap(head);
4859    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4860}
4861
4862/// Resolves the linked worktree whose working directory canonicalizes to
4863/// `target` to its registered name in `main_repo`. Errors when `target` is not
4864/// one of the repo's worktrees — the defensive guard against removing a path
4865/// that opened as a worktree but is not enumerated. Split out so that guard is
4866/// unit-testable without corrupting git's worktree admin state.
4867fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4868    let names = main_repo.worktrees()?;
4869    names
4870        .iter()
4871        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
4872        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
4873        .find(|name| {
4874            main_repo
4875                .find_worktree(name)
4876                .is_ok_and(|wt| canonical(wt.path()) == target)
4877        })
4878        .map(str::to_string)
4879        .ok_or_else(|| {
4880            anyhow!(
4881                "worktree {} is not registered in {}",
4882                target.display(),
4883                main_repo.path().display()
4884            )
4885        })
4886}
4887
4888/// Backoff delays between recursive-removal retries (#1315). A concurrent
4889/// writer — a just-closed window's language server (Metals/Bloop) or
4890/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
4891/// create a file between our directory scan and its `rmdir`, making the removal
4892/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
4893/// waits longer, giving the winding-down process time to quiesce. Total wait
4894/// ~2.75s across four retries; the window teardown the caller already waited on
4895/// dominates it.
4896const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4897    Duration::from_millis(250),
4898    Duration::from_millis(500),
4899    Duration::from_secs(1),
4900    Duration::from_secs(1),
4901];
4902
4903/// Whether `e` is the transient "directory re-populated under us" race we retry
4904/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
4905/// denied, read-only filesystem) we must surface immediately. Matches the raw
4906/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
4907/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
4908fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4909    matches!(
4910        e.raw_os_error(),
4911        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4912    )
4913}
4914
4915/// Recursively removes `dir`, retrying on the transient concurrent-writer race
4916/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
4917/// as success. Non-transient errors surface immediately with the original
4918/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
4919/// `spawn_blocking`), so the between-retry `sleep` is fine.
4920fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4921    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4922}
4923
4924/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
4925/// Provoking the real race requires a concurrent writer to lose a timing window,
4926/// so only an injected sequence of errors can drive every branch of the loop —
4927/// exhausting the backoff especially — deterministically and without sleeping out
4928/// the production schedule.
4929fn remove_dir_all_retrying_with(
4930    dir: &Path,
4931    backoff: &[Duration],
4932    mut remove: impl FnMut() -> std::io::Result<()>,
4933) -> Result<()> {
4934    let mut backoff = backoff.iter();
4935    loop {
4936        match remove() {
4937            Ok(()) => return Ok(()),
4938            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4939            Err(e) => {
4940                if is_transient_rmdir_error(&e) {
4941                    if let Some(delay) = backoff.next() {
4942                        std::thread::sleep(*delay);
4943                        continue;
4944                    }
4945                }
4946                return Err(e).with_context(|| {
4947                    format!("failed to remove worktree directory {}", dir.display())
4948                });
4949            }
4950        }
4951    }
4952}
4953
4954/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
4955/// still points at an admin directory a prior failed removal already deleted.
4956/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
4957/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
4958/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
4959/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
4960/// opens) and a normal checkout has a `.git` *directory*, so this matches
4961/// neither.
4962fn is_orphaned_worktree(path: &Path) -> bool {
4963    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
4964    // a linked worktree's gitlink file gets past here.
4965    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4966        return false;
4967    };
4968    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4969        return false;
4970    };
4971    let admin = Path::new(admin);
4972    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
4973    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4974}
4975
4976/// The outcome of a linked-worktree removal, so the audit log can tell "actually
4977/// removed something" from "nothing was there" (#1403). Before this the
4978/// working-tree-gone-but-admin-present case returned `Ok(())` and logged a
4979/// `pruned` lie, leaving the row stuck in the tree view.
4980#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4981enum Removal {
4982    /// The admin metadata (and possibly the working tree) was actually removed.
4983    Pruned,
4984    /// Nothing was there to remove — a truly already-removed worktree.
4985    AlreadyGone,
4986}
4987
4988/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
4989/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
4990/// directory and the admin metadata. Refuses the main working tree (the
4991/// defensive backstop behind the UI gating) and a locked worktree (surfacing
4992/// "unlock first" rather than forcing past the lock). Idempotent: an
4993/// already-removed path is a success.
4994///
4995/// The working tree is removed **first** (retrying to absorb the
4996/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
4997/// This is deliberately the reverse of libgit2's combined
4998/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
4999/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
5000/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
5001/// is already gone). Doing the directory first means a transient failure leaves
5002/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
5003/// the old ordering is detected and its leftover directory cleaned up directly.
5004///
5005/// A working directory that is *already gone* is **not** blindly treated as a
5006/// no-op: a half-removal from outside the daemon (a manual `rm -rf`, an OS
5007/// cleanup) can leave the main repo's `.git/worktrees/<name>/` admin entry behind
5008/// (git marks it `prunable` and the tree view keeps showing the row). That path
5009/// hands off to [`prune_orphaned_admin`], which locates the owning main repo from
5010/// `windows` (or the path's ancestors) and prunes just that entry; only when no
5011/// repo still tracks the path is it reported [`Removal::AlreadyGone`] (#1403).
5012fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
5013    if !path.exists() {
5014        return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
5015    }
5016    let repo = match Repository::open(path) {
5017        Ok(repo) => repo,
5018        // Admin metadata already gone (a prior failed removal); git no longer
5019        // tracks this path, so no prune applies — just delete the leftover.
5020        Err(_) if is_orphaned_worktree(path) => {
5021            remove_dir_all_retrying(path)?;
5022            return Ok(Removal::Pruned);
5023        }
5024        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
5025    };
5026    if !repo.is_worktree() {
5027        bail!(
5028            "refusing to delete the main working tree: {}",
5029            path.display()
5030        );
5031    }
5032    // The Worktree handle lives on the *main* repo (the common dir's parent),
5033    // keyed by name; find it by matching the target path.
5034    let commondir = canonical(repo.commondir());
5035    let main_root = commondir
5036        .parent()
5037        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5038        .to_path_buf();
5039    // Drop the worktree-scoped handle before we delete its directory.
5040    drop(repo);
5041    let main_repo = Repository::open(&main_root)
5042        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5043    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5044    let worktree = main_repo.find_worktree(&name)?;
5045
5046    // Never silently force past a lock (failure mode #6).
5047    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5048        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5049        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5050    }
5051
5052    // Delete the checked-out directory ourselves, retrying past the
5053    // concurrent-writer race (#1315).
5054    remove_dir_all_retrying(path)?;
5055
5056    // The directory is gone; prune only the admin metadata. working_tree(false)
5057    // keeps git2 from re-attempting (and failing on) the now-absent directory;
5058    // valid(true) prunes even though the worktree was valid; locked stays false,
5059    // so a lock (re-checked above) is never forced.
5060    let mut opts = git2::WorktreePruneOptions::new();
5061    opts.valid(true).working_tree(false);
5062    worktree
5063        .prune(Some(&mut opts))
5064        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5065    Ok(Removal::Pruned)
5066}
5067
5068/// The main-repo roots to search when pruning an orphaned worktree whose working
5069/// directory is already gone (#1403). There is no on-disk breadcrumb from the
5070/// vanished working tree back to its repo — the `.git` gitlink lived *inside* the
5071/// deleted directory — so the owner has to be found by enumerating candidates:
5072///
5073/// - **the path's own ancestors**, covering a worktree nested under its repo
5074///   (e.g. `<repo>/.claude/worktrees/<name>`): an existing ancestor that opens as
5075///   the *main* checkout is the owner. `Repository::open` (not `discover`) so only
5076///   a real repo-root ancestor matches, never an intermediate directory.
5077/// - **every main repo the live `windows` resolve to**, covering an external
5078///   worktree that shares no ancestor with its repo. These are the same repos
5079///   whose `worktrees()` enumeration produced the orphaned row, so this is
5080///   guaranteed to include the owner whenever the UI could show a row to close.
5081///
5082/// Deduped, order-preserving (ancestors first).
5083fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5084    let mut roots: Vec<PathBuf> = Vec::new();
5085    let mut push = |root: PathBuf| {
5086        if !roots.contains(&root) {
5087            roots.push(root);
5088        }
5089    };
5090    // Skip `path` itself (gone) via `skip(1)`.
5091    for ancestor in path.ancestors().skip(1) {
5092        if let Ok(repo) = Repository::open(ancestor) {
5093            if !repo.is_worktree() {
5094                if let Some(root) = canonical(repo.commondir()).parent() {
5095                    push(root.to_path_buf());
5096                }
5097            }
5098        }
5099    }
5100    for folder in windows.iter().flat_map(|w| &w.folders) {
5101        if let Ok(repo) = Repository::discover(folder) {
5102            if let Some(root) = canonical(repo.commondir()).parent() {
5103                push(root.to_path_buf());
5104            }
5105        }
5106    }
5107    roots
5108}
5109
5110/// Prunes the leftover `.git/worktrees/<name>/` admin metadata of a worktree
5111/// whose working directory is already gone (#1403). Searches
5112/// `candidate_main_repos` for the main repo that still tracks a worktree
5113/// registered at `path`, prunes just that entry's metadata (`working_tree(false)`
5114/// — the checkout is already gone), and returns [`Removal::Pruned`]. When no
5115/// candidate still tracks the path it is truly already-removed:
5116/// [`Removal::AlreadyGone`]. A locked entry is refused, mirroring
5117/// [`remove_worktree`]'s live path, rather than forced past.
5118fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5119    let target = canonical(path);
5120    for root in candidate_main_repos {
5121        let Ok(main_repo) = Repository::open(root) else {
5122            continue;
5123        };
5124        // Only the main checkout carries the `.git/worktrees/<name>/` admin dir.
5125        if main_repo.is_worktree() {
5126            continue;
5127        }
5128        // Not the owner (or the entry is already pruned) — keep looking.
5129        let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5130            continue;
5131        };
5132        let worktree = main_repo.find_worktree(&name)?;
5133        if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5134            let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5135            bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5136        }
5137        let mut opts = git2::WorktreePruneOptions::new();
5138        opts.valid(true).working_tree(false);
5139        worktree.prune(Some(&mut opts)).with_context(|| {
5140            format!(
5141                "failed to prune orphaned worktree metadata for {}",
5142                path.display()
5143            )
5144        })?;
5145        return Ok(Removal::Pruned);
5146    }
5147    Ok(Removal::AlreadyGone)
5148}
5149
5150#[cfg(test)]
5151#[allow(clippy::unwrap_used, clippy::expect_used)]
5152mod tests {
5153    use super::*;
5154    use crate::test_support::shim::{
5155        retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5156    };
5157    use std::sync::MutexGuard;
5158
5159    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5160        json!({
5161            "key": key,
5162            "folders": [folder],
5163            "repo": repo,
5164            "title": format!("{key}-title"),
5165            "pid": 1234,
5166        })
5167    }
5168
5169    /// Pulls the `windows` array out of a `list`/`status` payload.
5170    fn windows_of(payload: &Value) -> &Vec<Value> {
5171        payload
5172            .get("windows")
5173            .and_then(Value::as_array)
5174            .expect("windows array")
5175    }
5176
5177    #[tokio::test]
5178    async fn name_and_unknown_op() {
5179        let svc = WorktreesService::new();
5180        assert_eq!(svc.name(), "worktrees");
5181        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5182    }
5183
5184    #[tokio::test]
5185    async fn handle_routes_ops_and_shapes_payloads() {
5186        let svc = WorktreesService::new();
5187        // Empty to start.
5188        let payload = svc.handle("list", Value::Null).await.unwrap();
5189        assert_eq!(payload, json!({ "windows": [] }));
5190
5191        // register → { ok: true }, then it shows up in list.
5192        let reply = svc
5193            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5194            .await
5195            .unwrap();
5196        assert_eq!(reply, json!({ "ok": true }));
5197        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5198        assert_eq!(windows.len(), 1);
5199        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5200        assert!(windows[0].get("last_seen").is_some());
5201
5202        // heartbeat known/unknown.
5203        let known = svc
5204            .handle("heartbeat", json!({ "key": "w1" }))
5205            .await
5206            .unwrap();
5207        assert_eq!(known, json!({ "known": true }));
5208        let unknown = svc
5209            .handle("heartbeat", json!({ "key": "nope" }))
5210            .await
5211            .unwrap();
5212        assert_eq!(unknown, json!({ "known": false }));
5213
5214        // reload signals a live window and reports one it does not know.
5215        let reloaded = svc
5216            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5217            .await
5218            .unwrap();
5219        assert_eq!(
5220            reloaded,
5221            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5222        );
5223        assert!(svc.registry.take_reload_pending("w1"));
5224
5225        // unregister removes, then repeats as a no-op success.
5226        let gone = svc
5227            .handle("unregister", json!({ "key": "w1" }))
5228            .await
5229            .unwrap();
5230        assert_eq!(gone, json!({ "removed": true }));
5231        let again = svc
5232            .handle("unregister", json!({ "key": "w1" }))
5233            .await
5234            .unwrap();
5235        assert_eq!(again, json!({ "removed": false }));
5236    }
5237
5238    // --- Reposition op (#1407) ------------------------------------------------
5239
5240    /// A window backend over an in-memory window table that **actually applies**
5241    /// writes, so the adapter's own responsibilities — key resolution, the undo
5242    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
5243    /// no Accessibility grant. The planner/matcher itself is covered by
5244    /// `geometry`'s own tests.
5245    ///
5246    /// Applying the writes is what makes a reposition-then-undo round trip mean
5247    /// anything: against a fixed table the restore would find every window already
5248    /// in its wanted position and correctly report `unchanged`.
5249    #[derive(Clone)]
5250    struct StubBackend {
5251        trusted: bool,
5252        /// One application's windows. `Arc` so every clone the factory hands out —
5253        /// and every op in a test — shares the same mutating table.
5254        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5255        /// Every frame written, in order.
5256        writes: Arc<Mutex<Vec<geometry::Frame>>>,
5257    }
5258
5259    impl StubBackend {
5260        /// One application (pid 900) with two default-format VS Code windows, and
5261        /// two ext-host pids (11, 12) mapping onto it.
5262        fn new(trusted: bool) -> Self {
5263            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5264                title: title.to_string(),
5265                frame: geometry::Frame {
5266                    x,
5267                    y: 0.0,
5268                    width,
5269                    height: 600.0,
5270                },
5271                minimized: false,
5272                fullscreen: false,
5273                standard: true,
5274                focused: false,
5275            };
5276            Self {
5277                trusted,
5278                windows: Arc::new(Mutex::new(vec![
5279                    window("plan.md — ref-tree", 0.0, 800.0),
5280                    window("main.rs — other-tree", 900.0, 500.0),
5281                ])),
5282                writes: Arc::new(Mutex::new(Vec::new())),
5283            }
5284        }
5285
5286        fn writes(&self) -> Vec<geometry::Frame> {
5287            self.writes
5288                .lock()
5289                .unwrap_or_else(PoisonError::into_inner)
5290                .clone()
5291        }
5292
5293        /// The frame a window currently occupies, after any applied writes.
5294        fn frame_of(&self, index: usize) -> geometry::Frame {
5295            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5296        }
5297
5298        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
5299        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5300            let clone = self.clone();
5301            move || clone
5302        }
5303    }
5304
5305    impl geometry::WindowBackend for StubBackend {
5306        fn trusted(&self) -> bool {
5307            self.trusted
5308        }
5309
5310        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5311            pids.iter()
5312                .filter(|p| **p == 11 || **p == 12)
5313                .map(|p| (*p, 900))
5314                .collect()
5315        }
5316
5317        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5318            if app_pid != 900 {
5319                return Ok(Vec::new());
5320            }
5321            Ok(self
5322                .windows
5323                .lock()
5324                .unwrap_or_else(PoisonError::into_inner)
5325                .clone())
5326        }
5327
5328        fn set_frame(
5329            &self,
5330            id: geometry::WindowId,
5331            frame: geometry::Frame,
5332        ) -> Result<geometry::Frame, String> {
5333            self.writes
5334                .lock()
5335                .unwrap_or_else(PoisonError::into_inner)
5336                .push(frame);
5337            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5338            let window = windows
5339                .get_mut(id.index)
5340                .ok_or_else(|| format!("no window at index {}", id.index))?;
5341            window.frame = frame;
5342            Ok(frame)
5343        }
5344    }
5345
5346    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
5347    /// one the stub backend can resolve to an OS window.
5348    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5349        svc.registry.register(
5350            serde_json::from_value(json!({
5351                "key": key,
5352                "folders": [format!("/tmp/{key}")],
5353                "title": title,
5354                "pid": pid,
5355            }))
5356            .expect("valid register payload"),
5357        );
5358    }
5359
5360    #[tokio::test]
5361    async fn reposition_requires_a_resolvable_reference() {
5362        let svc = WorktreesService::new();
5363        // A missing, blank, or unknown reference key is a hard error: with no
5364        // reference there is no geometry to copy, so the request is meaningless.
5365        assert!(svc.handle("reposition", json!({})).await.is_err());
5366        assert!(svc
5367            .handle("reposition", json!({ "reference_key": "  " }))
5368            .await
5369            .is_err());
5370        assert!(svc
5371            .handle("reposition", json!({ "reference_key": "ghost" }))
5372            .await
5373            .is_err());
5374    }
5375
5376    #[tokio::test]
5377    async fn reposition_moves_targets_and_records_an_undo() {
5378        let svc = WorktreesService::new();
5379        register_window(&svc, "ref", "ref-tree", 11);
5380        register_window(&svc, "other", "other-tree", 12);
5381        let backend = StubBackend::new(true);
5382
5383        let reply = svc
5384            .reposition_with(
5385                serde_json::from_value(json!({
5386                    "reference_key": "ref",
5387                    "target_keys": ["other"],
5388                }))
5389                .unwrap(),
5390                backend.factory(),
5391            )
5392            .await
5393            .unwrap();
5394
5395        assert_eq!(reply["trusted"], json!(true));
5396        assert_eq!(reply["moved"], json!(1));
5397        assert_eq!(reply["skipped"], json!(0));
5398        assert_eq!(reply["undoable"], json!(true));
5399        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5400        assert_eq!(reply["results"][0]["key"], json!("other"));
5401        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5402        // Written the reference's own frame, read from the stub's window table.
5403        assert_eq!(backend.writes().len(), 1);
5404        assert_eq!(
5405            backend.writes()[0],
5406            backend.frame_of(0),
5407            "wrote the reference window's own frame"
5408        );
5409        assert_eq!(
5410            backend.frame_of(1),
5411            backend.frame_of(0),
5412            "the target now occupies the reference's frame"
5413        );
5414
5415        // Undo puts it back where it was — the same backend, so it sees the window
5416        // where the move left it — and consumes the record, so a second undo has
5417        // nothing left to replay onto a layout the user may have since redone.
5418        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5419        assert_eq!(undone["moved"], json!(1));
5420        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5421        assert!(undone.get("reference").is_none(), "undo has no reference");
5422        assert_eq!(
5423            backend.frame_of(1),
5424            geometry::Frame {
5425                x: 900.0,
5426                y: 0.0,
5427                width: 500.0,
5428                height: 600.0,
5429            },
5430            "restored to exactly the pre-move frame"
5431        );
5432
5433        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5434        assert_eq!(
5435            again,
5436            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5437        );
5438    }
5439
5440    #[tokio::test]
5441    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5442        let svc = WorktreesService::new();
5443        register_window(&svc, "ref", "ref-tree", 11);
5444        register_window(&svc, "other", "other-tree", 12);
5445        let backend = StubBackend::new(true);
5446
5447        let reply = svc
5448            .reposition_with(
5449                serde_json::from_value(json!({
5450                    "reference_key": "ref",
5451                    "target_keys": ["other"],
5452                    "check": true,
5453                }))
5454                .unwrap(),
5455                backend.factory(),
5456            )
5457            .await
5458            .unwrap();
5459
5460        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5461        assert!(
5462            reply.get("undoable").is_none(),
5463            "a dry run leaves nothing to undo"
5464        );
5465        assert!(
5466            backend.writes().is_empty(),
5467            "a dry run must not touch a window"
5468        );
5469    }
5470
5471    #[tokio::test]
5472    async fn reposition_reports_a_missing_permission_as_data() {
5473        let svc = WorktreesService::new();
5474        register_window(&svc, "ref", "ref-tree", 11);
5475        register_window(&svc, "other", "other-tree", 12);
5476        let backend = StubBackend::new(false);
5477
5478        let reply = svc
5479            .reposition_with(
5480                serde_json::from_value(json!({
5481                    "reference_key": "ref",
5482                    "target_keys": ["other"],
5483                }))
5484                .unwrap(),
5485                backend.factory(),
5486            )
5487            .await
5488            .unwrap();
5489
5490        // Not an error: the client branches on `trusted` to offer the user a link
5491        // to the Accessibility settings pane.
5492        assert_eq!(reply["trusted"], json!(false));
5493        assert_eq!(reply["moved"], json!(0));
5494        assert!(backend.writes().is_empty());
5495    }
5496
5497    #[tokio::test]
5498    async fn a_stale_target_key_is_skipped_not_fatal() {
5499        let svc = WorktreesService::new();
5500        register_window(&svc, "ref", "ref-tree", 11);
5501        register_window(&svc, "other", "other-tree", 12);
5502        let backend = StubBackend::new(true);
5503
5504        let reply = svc
5505            .reposition_with(
5506                serde_json::from_value(json!({
5507                    "reference_key": "ref",
5508                    // A window that closed since the tree row was rendered, the
5509                    // reference itself, and a real target.
5510                    "target_keys": ["closed-since", "ref", "other"],
5511                }))
5512                .unwrap(),
5513                backend.factory(),
5514            )
5515            .await
5516            .unwrap();
5517
5518        let outcomes: Vec<&str> = reply["results"]
5519            .as_array()
5520            .unwrap()
5521            .iter()
5522            .map(|r| r["outcome"].as_str().unwrap())
5523            .collect();
5524        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5525        assert_eq!(reply["moved"], json!(1));
5526        assert_eq!(reply["skipped"], json!(2));
5527    }
5528
5529    #[tokio::test]
5530    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5531        let svc = WorktreesService::new();
5532        // Two windows share a root name, so the *reference* cannot be resolved and
5533        // the whole batch is refused before any target is attempted.
5534        register_window(&svc, "ref", "twin", 11);
5535        register_window(&svc, "other", "other-tree", 12);
5536        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5537        // `mut` binding — and the same shared table backs the factory's clone.
5538        let backend = StubBackend::new(true);
5539        {
5540            let mut windows = backend
5541                .windows
5542                .lock()
5543                .unwrap_or_else(PoisonError::into_inner);
5544            windows[0].title = "a.rs — twin".to_string();
5545            windows[1].title = "b.rs — twin".to_string();
5546        }
5547
5548        let reply = svc
5549            .reposition_with(
5550                serde_json::from_value(json!({
5551                    "reference_key": "ref",
5552                    "target_keys": ["other"],
5553                }))
5554                .unwrap(),
5555                backend.factory(),
5556            )
5557            .await
5558            .unwrap();
5559
5560        assert_eq!(reply["trusted"], json!(true));
5561        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5562        assert!(
5563            reply["blocked"]["detail"]
5564                .as_str()
5565                .is_some_and(|d| d.contains("twin")),
5566            "the reason should name the ambiguous title: {reply}"
5567        );
5568        assert_eq!(reply["results"], json!([]), "no target is attempted");
5569        assert!(reply.get("undoable").is_none());
5570        assert!(backend.writes().is_empty());
5571
5572        // And nothing was recorded, so a following undo has nothing to replay.
5573        let undone = svc
5574            .reposition_undo_with(StubBackend::new(true).factory())
5575            .await
5576            .unwrap();
5577        assert_eq!(undone["moved"], json!(0));
5578    }
5579
5580    #[tokio::test]
5581    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5582        let svc = WorktreesService::new();
5583        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5584        assert_eq!(reply["moved"], json!(0));
5585        assert_eq!(reply["results"], json!([]));
5586    }
5587
5588    #[test]
5589    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5590        let empty = geometry::RepositionReport {
5591            trusted: true,
5592            blocked: None,
5593            reference: None,
5594            results: Vec::new(),
5595            undo: Vec::new(),
5596        };
5597        assert_eq!(outcome_kinds(&empty), "-");
5598    }
5599
5600    #[tokio::test]
5601    async fn handle_rejects_missing_or_empty_key() {
5602        let svc = WorktreesService::new();
5603        // register validates a present, non-blank key.
5604        assert!(svc.handle("register", json!({})).await.is_err());
5605        assert!(svc
5606            .handle("register", json!({ "key": "  " }))
5607            .await
5608            .is_err());
5609        // heartbeat/unregister require the key via `require_str`.
5610        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5611        assert!(svc.handle("unregister", json!({})).await.is_err());
5612    }
5613
5614    #[test]
5615    fn display_name_prefers_repo_then_folder_basename() {
5616        let base = WindowEntry {
5617            key: "k".to_string(),
5618            folders: vec![PathBuf::from("/home/me/project")],
5619            repo: Some("my-repo".to_string()),
5620            title: None,
5621            pid: None,
5622            last_seen: Utc::now(),
5623        };
5624        assert_eq!(display_name(&base), "my-repo");
5625
5626        let no_repo = WindowEntry {
5627            repo: None,
5628            ..base.clone()
5629        };
5630        assert_eq!(display_name(&no_repo), "project");
5631
5632        let nothing = WindowEntry {
5633            repo: None,
5634            folders: vec![],
5635            ..base.clone()
5636        };
5637        assert_eq!(display_name(&nothing), "(no folder)");
5638
5639        // A folder with no basename (the filesystem root) falls back to its
5640        // displayed path rather than panicking or yielding an empty name.
5641        let rootish = WindowEntry {
5642            repo: None,
5643            folders: vec![PathBuf::from("/")],
5644            ..base
5645        };
5646        assert_eq!(display_name(&rootish), "/");
5647    }
5648
5649    #[test]
5650    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5651        let now = Utc::now();
5652        let entries = vec![
5653            // A folderless window has nothing to focus, so it stays a plain
5654            // Label; a title equal to the name collapses to just the name. It
5655            // leads the list so the focus-action lookup below is exercised
5656            // against a leading non-Action item it has to skip.
5657            WindowEntry {
5658                key: "k2".to_string(),
5659                folders: vec![],
5660                repo: Some("solo".to_string()),
5661                title: Some("solo".to_string()),
5662                pid: None,
5663                last_seen: now,
5664            },
5665            // A folder-bearing, non-repo window: one clickable Action whose label
5666            // is the stats line ("name · title", since /tmp is not a git repo).
5667            WindowEntry {
5668                key: "k1".to_string(),
5669                folders: vec![PathBuf::from("/tmp/a")],
5670                repo: Some("repo".to_string()),
5671                title: Some("a branch".to_string()),
5672                pid: None,
5673                last_seen: now,
5674            },
5675        ];
5676        let items = window_menu_items(&entries);
5677        // Exactly one item per window — no duplicate label, no separator.
5678        assert_eq!(items.len(), 2);
5679        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5680
5681        // The folder-bearing window is a single clickable action carrying the
5682        // stats label (the old label + Focus action, merged).
5683        let action = items
5684            .iter()
5685            .find_map(|i| match i {
5686                MenuItem::Action(a) => Some(a),
5687                _ => None,
5688            })
5689            .expect("a focus action");
5690        assert_eq!(action.id, "focus:k1");
5691        assert_eq!(action.label, "repo · a branch");
5692
5693        // The folderless window is a non-clickable label (not "solo · solo").
5694        let labels: Vec<&str> = items
5695            .iter()
5696            .filter_map(|i| match i {
5697                MenuItem::Label(t) => Some(t.as_str()),
5698                _ => None,
5699            })
5700            .collect();
5701        assert_eq!(labels, vec!["solo"]);
5702    }
5703
5704    #[tokio::test]
5705    async fn menu_and_status_shapes() {
5706        let svc = WorktreesService::new();
5707        // Empty.
5708        let menu = svc.menu();
5709        assert_eq!(menu.title, "Worktrees");
5710        assert!(matches!(
5711            menu.items.first(),
5712            Some(MenuItem::Label(text)) if text == "No open windows"
5713        ));
5714        let status = svc.status().await;
5715        assert_eq!(status.name, "worktrees");
5716        assert!(status.healthy);
5717        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5718
5719        // Two folder-bearing windows in the same repo, plus one folderless
5720        // window that shares the repo but has nothing for `code` to open.
5721        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5722            .await
5723            .unwrap();
5724        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5725            .await
5726            .unwrap();
5727        svc.handle(
5728            "register",
5729            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5730        )
5731        .await
5732        .unwrap();
5733        let status = svc.status().await;
5734        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5735
5736        let menu = svc.menu();
5737        // One line per window — no separator, no duplicate label.
5738        assert_eq!(menu.items.len(), 3);
5739        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5740        let action_ids: Vec<&str> = menu
5741            .items
5742            .iter()
5743            .filter_map(|i| match i {
5744                MenuItem::Action(a) => Some(a.id.as_str()),
5745                _ => None,
5746            })
5747            .collect();
5748        // The two folder-bearing windows are clickable; the folderless one is a
5749        // plain Label, so it never yields a focus action.
5750        assert!(action_ids.contains(&"focus:w1"));
5751        assert!(action_ids.contains(&"focus:w2"));
5752        assert!(!action_ids.contains(&"focus:w3"));
5753    }
5754
5755    #[test]
5756    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5757        // With no tokio runtime, the background task is never spawned, so the
5758        // bare service keeps computing `menu()` inline (what the tests rely on).
5759        let svc = WorktreesService::new();
5760        svc.start_menu_refresh();
5761        assert!(svc.refresh.lock().unwrap().is_none());
5762    }
5763
5764    #[tokio::test]
5765    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5766        let svc = WorktreesService::new();
5767        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5768            .await
5769            .unwrap();
5770        // Before the task runs, `menu()` computes inline from an empty cache.
5771        assert!(svc.menu_cache.lock().unwrap().is_none());
5772
5773        svc.start_menu_refresh();
5774        // Idempotent: a second call does not start a second task.
5775        svc.start_menu_refresh();
5776
5777        // The task fills the cache off the main thread; poll briefly for it.
5778        let mut filled = false;
5779        for _ in 0..100 {
5780            if svc.menu_cache.lock().unwrap().is_some() {
5781                filled = true;
5782                break;
5783            }
5784            tokio::time::sleep(Duration::from_millis(10)).await;
5785        }
5786        assert!(filled, "background refresh should populate the menu cache");
5787
5788        // `menu()` now serves the cache: one clickable line for the window.
5789        let menu = svc.menu();
5790        assert_eq!(menu.title, "Worktrees");
5791        assert!(menu
5792            .items
5793            .iter()
5794            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5795
5796        // Shutdown cancels and joins the task, clearing the handle.
5797        svc.shutdown().await;
5798        assert!(svc.refresh.lock().unwrap().is_none());
5799    }
5800
5801    #[tokio::test]
5802    async fn default_constructs_an_empty_service() {
5803        let svc = WorktreesService::default();
5804        let payload = svc.handle("list", Value::Null).await.unwrap();
5805        assert_eq!(payload, json!({ "windows": [] }));
5806    }
5807
5808    // --- Push subscription (#1267) -----------------------------------------
5809
5810    #[tokio::test]
5811    async fn subscribe_streams_only_for_the_subscribe_op() {
5812        let svc = WorktreesService::new();
5813        // The one streaming op yields a stream; every other op (including the
5814        // request/reply worktrees ops) declines, so the server dispatches them
5815        // normally.
5816        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5817        assert!(svc.subscribe("list", &Value::Null).is_none());
5818        assert!(svc.subscribe("register", &Value::Null).is_none());
5819        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5820    }
5821
5822    #[tokio::test]
5823    async fn subscribe_snapshot_matches_the_tree_op() {
5824        let dir = tempfile::tempdir().unwrap();
5825        let repo = init_repo(dir.path());
5826        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5827        repo.set_head("refs/heads/main").unwrap();
5828
5829        let svc = WorktreesService::new();
5830        let stream = svc
5831            .subscribe("subscribe", &Value::Null)
5832            .expect("subscribe stream");
5833        // No windows yet → no repos derived; the toggle rides along at its
5834        // default (show all).
5835        assert_eq!(
5836            stream.snapshot().await,
5837            json!({ "repos": [], "show_closed": true })
5838        );
5839
5840        // A window opens on the repo → the snapshot carries it, byte-identical to
5841        // what the `tree` op returns for the same registry state.
5842        svc.handle(
5843            "register",
5844            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5845        )
5846        .await
5847        .unwrap();
5848        let snap = stream.snapshot().await;
5849        let tree = svc.handle("tree", Value::Null).await.unwrap();
5850        assert_eq!(snap, tree);
5851        let repos = snap["repos"].as_array().expect("repos array");
5852        assert_eq!(repos.len(), 1);
5853        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5854    }
5855
5856    #[tokio::test]
5857    async fn subscribe_changed_wakes_on_register() {
5858        let svc = WorktreesService::new();
5859        let mut stream = svc
5860            .subscribe("subscribe", &Value::Null)
5861            .expect("subscribe stream");
5862        // Idle: `changed()` must not resolve without a registry change.
5863        tokio::select! {
5864            () = stream.changed() => panic!("changed resolved with no registry change"),
5865            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5866        }
5867        // A register bumps the change-notify → `changed()` resolves promptly.
5868        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5869            .await
5870            .unwrap();
5871        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5872            .await
5873            .expect("changed should resolve after a register");
5874    }
5875
5876    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5877
5878    #[tokio::test]
5879    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5880        let reg = Arc::new(WorktreesRegistry::new());
5881        // A long TTL so only the generation gate is exercised here.
5882        let cache = TreeSnapshotCache::with_ttl(
5883            reg,
5884            Arc::new(PrStatusCache::new()),
5885            Duration::from_secs(60),
5886        );
5887        // The first read builds once.
5888        let first = cache.snapshot().await;
5889        assert_eq!(cache.compute_count(), 1);
5890        // Further reads with no registry change and within the TTL reuse the
5891        // cached value — no extra build, byte-identical result.
5892        let second = cache.snapshot().await;
5893        assert_eq!(
5894            cache.compute_count(),
5895            1,
5896            "an unchanged read must not rebuild"
5897        );
5898        assert_eq!(first, second);
5899    }
5900
5901    #[tokio::test]
5902    async fn tree_cache_single_flights_a_read_burst() {
5903        let reg = Arc::new(WorktreesRegistry::new());
5904        let cache = Arc::new(TreeSnapshotCache::with_ttl(
5905            reg,
5906            Arc::new(PrStatusCache::new()),
5907            Duration::from_secs(60),
5908        ));
5909        // A burst of concurrent readers — as N subscriber streams would wake
5910        // together on a change/tick — collapses to exactly one build; the rest
5911        // read the shared result (the acceptance criterion).
5912        let mut handles = Vec::new();
5913        for _ in 0..16 {
5914            let cache = cache.clone();
5915            handles.push(tokio::spawn(async move { cache.snapshot().await }));
5916        }
5917        let mut results = Vec::new();
5918        for handle in handles {
5919            results.push(handle.await.unwrap());
5920        }
5921        assert_eq!(
5922            cache.compute_count(),
5923            1,
5924            "a concurrent read burst must build the tree once"
5925        );
5926        assert!(
5927            results.windows(2).all(|w| w[0] == w[1]),
5928            "every reader must observe the identical snapshot"
5929        );
5930    }
5931
5932    #[tokio::test]
5933    async fn tree_cache_rebuilds_on_registry_change() {
5934        let reg = Arc::new(WorktreesRegistry::new());
5935        let cache = TreeSnapshotCache::with_ttl(
5936            reg.clone(),
5937            Arc::new(PrStatusCache::new()),
5938            Duration::from_secs(60),
5939        );
5940        cache.snapshot().await;
5941        assert_eq!(cache.compute_count(), 1);
5942        // A registry change bumps the generation, so the next read rebuilds even
5943        // though the (long) TTL has not expired — subscribers never see a stale
5944        // visible set.
5945        assert!(reg.set_show_closed(false));
5946        cache.snapshot().await;
5947        assert_eq!(
5948            cache.compute_count(),
5949            2,
5950            "a generation bump must force a rebuild"
5951        );
5952    }
5953
5954    #[tokio::test]
5955    async fn tree_cache_rebuilds_after_ttl_expiry() {
5956        let reg = Arc::new(WorktreesRegistry::new());
5957        // A zero TTL: every read is already past it, so a pure on-disk git change
5958        // still surfaces on the next tick with no registry bump needed.
5959        let cache =
5960            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5961        cache.snapshot().await;
5962        cache.snapshot().await;
5963        assert_eq!(
5964            cache.compute_count(),
5965            2,
5966            "an expired TTL must force a rebuild each read"
5967        );
5968    }
5969
5970    #[tokio::test]
5971    async fn subscribe_streams_share_one_build_per_generation() {
5972        let svc = WorktreesService::new();
5973        let s1 = svc
5974            .subscribe("subscribe", &Value::Null)
5975            .expect("subscribe stream");
5976        let s2 = svc
5977            .subscribe("subscribe", &Value::Null)
5978            .expect("subscribe stream");
5979        // Two windows' streams sampling the same registry state build the tree
5980        // once, not once per stream (#1303) — they share the service's cache.
5981        let a = s1.snapshot().await;
5982        let b = s2.snapshot().await;
5983        assert_eq!(a, b);
5984        assert_eq!(
5985            svc.tree_cache.compute_count(),
5986            1,
5987            "N streams on one generation must share a single build"
5988        );
5989    }
5990
5991    // --- Show/hide-closed toggle (#1301) -----------------------------------
5992
5993    #[tokio::test]
5994    async fn set_show_closed_toggles_the_snapshot_field() {
5995        let svc = WorktreesService::new();
5996        // The snapshot carries the toggle; it defaults to show-all.
5997        assert_eq!(
5998            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5999            json!(true)
6000        );
6001        // Setting it flips the field the next snapshot reports.
6002        let reply = svc
6003            .handle("set-show-closed", json!({ "show_closed": false }))
6004            .await
6005            .unwrap();
6006        assert_eq!(reply, json!({ "ok": true }));
6007        assert_eq!(
6008            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6009            json!(false)
6010        );
6011    }
6012
6013    #[tokio::test]
6014    async fn set_show_closed_rejects_a_non_boolean_payload() {
6015        let svc = WorktreesService::new();
6016        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
6017        assert!(svc
6018            .handle("set-show-closed", json!({ "show_closed": "yes" }))
6019            .await
6020            .is_err());
6021    }
6022
6023    #[tokio::test]
6024    async fn set_show_closed_wakes_the_subscription() {
6025        let svc = WorktreesService::new();
6026        let mut stream = svc
6027            .subscribe("subscribe", &Value::Null)
6028            .expect("subscribe stream");
6029        // A real flip bumps the change-notify → `changed()` resolves promptly.
6030        svc.handle("set-show-closed", json!({ "show_closed": false }))
6031            .await
6032            .unwrap();
6033        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6034            .await
6035            .expect("changed should resolve after a toggle flip");
6036        // The pushed snapshot now reflects the new toggle.
6037        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6038    }
6039
6040    #[tokio::test]
6041    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6042        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
6043        // drops it (skip-if-false), so the extension colours the icon off the flag.
6044        let dir = tempfile::tempdir().unwrap();
6045        github_repo(dir.path());
6046        let svc = WorktreesService::new();
6047        svc.handle(
6048            "register",
6049            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6050        )
6051        .await
6052        .unwrap();
6053
6054        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6055        assert!(
6056            repo.get("polling_enabled").is_none(),
6057            "default off omits the flag: {repo:?}"
6058        );
6059
6060        let reply = svc
6061            .handle(
6062                "set-polling",
6063                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6064            )
6065            .await
6066            .unwrap();
6067        assert_eq!(reply, json!({ "ok": true }));
6068        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6069        assert_eq!(repo["polling_enabled"], json!(true));
6070
6071        svc.handle(
6072            "set-polling",
6073            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6074        )
6075        .await
6076        .unwrap();
6077        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6078        assert!(repo.get("polling_enabled").is_none());
6079    }
6080
6081    #[tokio::test]
6082    async fn set_polling_rejects_missing_or_empty_fields() {
6083        let svc = WorktreesService::new();
6084        // Missing `enabled`.
6085        assert!(svc
6086            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6087            .await
6088            .is_err());
6089        // Missing `owner`/`name`.
6090        assert!(svc
6091            .handle("set-polling", json!({ "enabled": true }))
6092            .await
6093            .is_err());
6094        // Blank `owner`/`name`.
6095        assert!(svc
6096            .handle(
6097                "set-polling",
6098                json!({ "owner": " ", "name": "n", "enabled": true })
6099            )
6100            .await
6101            .is_err());
6102    }
6103
6104    #[tokio::test]
6105    async fn set_polling_wakes_the_subscription() {
6106        let dir = tempfile::tempdir().unwrap();
6107        github_repo(dir.path());
6108        let svc = WorktreesService::new();
6109        svc.handle(
6110            "register",
6111            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6112        )
6113        .await
6114        .unwrap();
6115        let mut stream = svc
6116            .subscribe("subscribe", &Value::Null)
6117            .expect("subscribe stream");
6118        svc.handle(
6119            "set-polling",
6120            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6121        )
6122        .await
6123        .unwrap();
6124        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6125            .await
6126            .expect("changed should resolve after enabling a repo");
6127        let repo = repos_of(&stream.snapshot().await)[0].clone();
6128        assert_eq!(repo["polling_enabled"], json!(true));
6129    }
6130
6131    #[tokio::test]
6132    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6133        // The "drop existing badges immediately" requirement (#1376), done
6134        // daemon-side: the fold skips a not-polled repo, so a disable strips the
6135        // badge on the very next snapshot rather than waiting for a poll.
6136        let dir = tempfile::tempdir().unwrap();
6137        let repo = github_repo(dir.path());
6138        let head = repo.head().unwrap().target().unwrap().to_string();
6139        let svc = WorktreesService::new();
6140        svc.handle(
6141            "register",
6142            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6143        )
6144        .await
6145        .unwrap();
6146        svc.registry.set_polling("rust-works", "omni-dev", true);
6147
6148        let mut badges = HashMap::new();
6149        badges.insert(
6150            PrTarget {
6151                owner: "rust-works".into(),
6152                name: "omni-dev".into(),
6153                branch: "main".into(),
6154            },
6155            pr(pending_badge(7, &head)),
6156        );
6157        svc.pr_cache.replace(badges);
6158
6159        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6160        assert_eq!(wt["pr"]["number"], json!(7));
6161
6162        svc.handle(
6163            "set-polling",
6164            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6165        )
6166        .await
6167        .unwrap();
6168        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6169        assert!(
6170            wt.get("pr").is_none(),
6171            "a disabled repo must carry no badge: {wt:?}"
6172        );
6173    }
6174
6175    #[tokio::test]
6176    async fn an_expired_lease_drops_the_flag_and_badges() {
6177        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
6178        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
6179        // poller would no longer watch it — all reaped on read, no timer.
6180        let dir = tempfile::tempdir().unwrap();
6181        let repo = github_repo(dir.path());
6182        let head = repo.head().unwrap().target().unwrap().to_string();
6183        let svc = WorktreesService::new();
6184        svc.handle(
6185            "register",
6186            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6187        )
6188        .await
6189        .unwrap();
6190        svc.registry.set_polling("rust-works", "omni-dev", true);
6191        let mut badges = HashMap::new();
6192        badges.insert(
6193            PrTarget {
6194                owner: "rust-works".into(),
6195                name: "omni-dev".into(),
6196                branch: "main".into(),
6197            },
6198            pr(pending_badge(7, &head)),
6199        );
6200        svc.pr_cache.replace(badges);
6201
6202        // Leased: flag stamped, badge folded, and the poller would watch it.
6203        let snap = svc.handle("tree", Value::Null).await.unwrap();
6204        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6205        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6206        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6207
6208        // Force the lease into the past — as 15 minutes elapsing would.
6209        svc.registry.set_polling_expiry(
6210            "rust-works",
6211            "omni-dev",
6212            Utc::now() - chrono::Duration::minutes(1),
6213        );
6214
6215        let snap = svc.handle("tree", Value::Null).await.unwrap();
6216        let repo0 = &repos_of(&snap)[0];
6217        assert!(
6218            repo0.get("polling_enabled").is_none(),
6219            "expired lease drops the flag: {repo0:?}"
6220        );
6221        assert!(
6222            repo0["worktrees"][0].get("pr").is_none(),
6223            "expired lease drops the badge"
6224        );
6225        assert!(
6226            pr_targets_from_snapshot(&snap).is_empty(),
6227            "the poller no longer watches an expired repo"
6228        );
6229    }
6230
6231    #[tokio::test]
6232    async fn polling_prefs_persist_across_reloads_with_0600() {
6233        // The enable set survives a daemon restart (#1376): a change writes the
6234        // `0600` file, and a fresh service seeded from it comes up enabled.
6235        let dir = tempfile::tempdir().unwrap();
6236        let prefs = dir.path().join("worktrees-polling.json");
6237
6238        let svc = WorktreesService::new();
6239        svc.load_polling_prefs(prefs.clone());
6240        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6241        svc.handle(
6242            "set-polling",
6243            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6244        )
6245        .await
6246        .unwrap();
6247        assert!(prefs.exists());
6248        #[cfg(unix)]
6249        {
6250            use std::os::unix::fs::PermissionsExt;
6251            assert_eq!(
6252                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6253                0o600
6254            );
6255        }
6256
6257        // A new service reloads the enabled set from that file.
6258        let svc2 = WorktreesService::new();
6259        svc2.load_polling_prefs(prefs.clone());
6260        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6261
6262        // Disabling rewrites the file, so the next reload has nothing enabled.
6263        svc2.handle(
6264            "set-polling",
6265            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6266        )
6267        .await
6268        .unwrap();
6269        let svc3 = WorktreesService::new();
6270        svc3.load_polling_prefs(prefs);
6271        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6272    }
6273
6274    #[test]
6275    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6276        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
6277        // path is logged and treated as "nothing enabled" rather than wedging the
6278        // service. A missing file is already the first-run default (covered by the
6279        // persistence round-trip above); this exercises the two error branches.
6280        let dir = tempfile::tempdir().unwrap();
6281
6282        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
6283        let corrupt = dir.path().join("worktrees-polling.json");
6284        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6285        let svc = WorktreesService::new();
6286        svc.load_polling_prefs(corrupt);
6287        assert!(svc.registry.enabled_polling_repos().is_empty());
6288
6289        // A directory at the path — the (non-NotFound) read error is swallowed too.
6290        let as_dir = dir.path().join("is-a-directory");
6291        std::fs::create_dir(&as_dir).unwrap();
6292        let svc2 = WorktreesService::new();
6293        svc2.load_polling_prefs(as_dir);
6294        assert!(svc2.registry.enabled_polling_repos().is_empty());
6295    }
6296
6297    #[tokio::test]
6298    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6299        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
6300        // the user has not enabled polling for it — so the poller spawns no `gh`.
6301        let dir = tempfile::tempdir().unwrap();
6302        github_repo(dir.path());
6303        let bin_dir = tempfile::tempdir().unwrap();
6304        let marker = bin_dir.path().join("spawned");
6305        let fake = bin_dir.path().join("fake-gh");
6306        std::fs::write(
6307            &fake,
6308            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6309        )
6310        .unwrap();
6311        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6312        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6313        std::fs::set_permissions(&fake, perms).unwrap();
6314
6315        let svc = WorktreesService::new();
6316        svc.handle(
6317            "register",
6318            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6319        )
6320        .await
6321        .unwrap();
6322        // Deliberately NOT enabling polling for the repo.
6323        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6324        tokio::time::sleep(Duration::from_millis(200)).await;
6325        svc.shutdown().await;
6326        assert!(
6327            !marker.exists(),
6328            "a registered-but-not-enabled repo must drive zero gh"
6329        );
6330    }
6331
6332    #[tokio::test]
6333    async fn menu_action_rejects_unknown_and_missing_window() {
6334        let svc = WorktreesService::new();
6335        assert!(svc.menu_action("bogus").await.is_err());
6336        // A focus for a key with no registration errors rather than spawning.
6337        assert!(svc.menu_action("focus:nope").await.is_err());
6338        svc.shutdown().await;
6339    }
6340
6341    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
6342    /// variable (via `resolve_code_binary` → `focus_window`) —
6343    /// `menu_action_focus_resolves_folder_and_spawns` and
6344    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
6345    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
6346    /// transient overlap under the harness's test parallelism is benign.
6347    struct VscodeBinGuard(Option<std::ffi::OsString>);
6348    impl Drop for VscodeBinGuard {
6349        fn drop(&mut self) {
6350            match self.0.take() {
6351                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6352                None => std::env::remove_var(VSCODE_BIN_ENV),
6353            }
6354        }
6355    }
6356
6357    #[tokio::test]
6358    async fn menu_action_focus_resolves_folder_and_spawns() {
6359        let dir = tempfile::tempdir().unwrap();
6360        let svc = WorktreesService::new();
6361        svc.handle(
6362            "register",
6363            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6364        )
6365        .await
6366        .unwrap();
6367
6368        // Point the launcher at a harmless binary so the spawn deterministically
6369        // succeeds and the focus path returns Ok.
6370        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6371        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6372        svc.menu_action("focus:w1").await.unwrap();
6373    }
6374
6375    #[tokio::test]
6376    async fn open_rejects_missing_relative_or_nonexistent_path() {
6377        let svc = WorktreesService::new();
6378        // A missing `path` is a payload error.
6379        assert!(svc.handle("open", json!({})).await.is_err());
6380        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6381        // A relative path is rejected before any spawn — this is also what
6382        // blocks a `-`-leading argument from reaching `code` as a flag.
6383        assert!(svc
6384            .handle("open", json!({ "path": "relative/dir" }))
6385            .await
6386            .is_err());
6387        assert!(svc
6388            .handle("open", json!({ "path": "-flag" }))
6389            .await
6390            .is_err());
6391        // An absolute path that does not exist is rejected before any spawn, so
6392        // no launcher is needed for these guard cases.
6393        assert!(svc
6394            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6395            .await
6396            .is_err());
6397        svc.shutdown().await;
6398    }
6399
6400    #[tokio::test]
6401    async fn open_focuses_an_existing_absolute_dir() {
6402        let dir = tempfile::tempdir().unwrap();
6403        let svc = WorktreesService::new();
6404        // Pin the launcher to a harmless binary so the spawn deterministically
6405        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6406        // path, `open` takes the folder straight from the payload — no prior
6407        // `register` is required.
6408        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6409        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6410        let reply = svc
6411            .handle("open", json!({ "path": dir.path() }))
6412            .await
6413            .unwrap();
6414        assert_eq!(reply, json!({ "ok": true }));
6415        svc.shutdown().await;
6416    }
6417
6418    #[test]
6419    fn focus_window_with_validates_folder_then_spawns() {
6420        let dir = tempfile::tempdir().unwrap();
6421        // Non-absolute and missing-directory folders are rejected before spawn.
6422        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6423        assert!(
6424            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6425        );
6426        // A valid absolute directory spawns the launcher successfully.
6427        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6428        // A missing launcher surfaces the spawn error (with context), not Ok.
6429        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6430    }
6431
6432    #[test]
6433    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6434        // Env override wins outright.
6435        assert_eq!(
6436            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6437            PathBuf::from("/custom/code")
6438        );
6439        // No override: the first existing candidate is chosen.
6440        let existing = tempfile::NamedTempFile::new().unwrap();
6441        let existing_path = existing.path().to_str().unwrap();
6442        assert_eq!(
6443            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6444            PathBuf::from(existing_path)
6445        );
6446        // Nothing exists: fall back to bare `code` on PATH.
6447        assert_eq!(
6448            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6449            PathBuf::from("code")
6450        );
6451        // The real-env wrapper resolves without panicking.
6452        let _ = resolve_code_binary();
6453    }
6454
6455    // --- Git enrichment (#1186) --------------------------------------------
6456
6457    /// Initializes a fresh repo with a deterministic identity so `commit()`
6458    /// works without depending on a global git config.
6459    fn init_repo(dir: &Path) -> Repository {
6460        let repo = Repository::init(dir).unwrap();
6461        let mut cfg = repo.config().unwrap();
6462        cfg.set_str("user.name", "Test").unwrap();
6463        cfg.set_str("user.email", "test@example.com").unwrap();
6464        repo
6465    }
6466
6467    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6468    /// optionally moving `refname` to it, and returns its oid.
6469    fn empty_commit(
6470        repo: &Repository,
6471        refname: Option<&str>,
6472        parents: &[&git2::Commit<'_>],
6473        msg: &str,
6474    ) -> git2::Oid {
6475        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6476        let tree = repo
6477            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6478            .unwrap();
6479        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6480            .unwrap()
6481    }
6482
6483    /// Commits `content` as file `name` onto `refname`, chaining off its current
6484    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6485    /// the file is checked out into a worktree and can then be modified to
6486    /// produce a dirty (tracked) status.
6487    fn commit_file(
6488        repo: &Repository,
6489        refname: &str,
6490        name: &str,
6491        content: &[u8],
6492        msg: &str,
6493    ) -> git2::Oid {
6494        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6495        let blob = repo.blob(content).unwrap();
6496        let mut builder = repo.treebuilder(None).unwrap();
6497        builder.insert(name, blob, 0o100_644).unwrap();
6498        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6499        let parent = repo
6500            .refname_to_id(refname)
6501            .ok()
6502            .and_then(|oid| repo.find_commit(oid).ok());
6503        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6504        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6505            .unwrap()
6506    }
6507
6508    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6509    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6510    fn diverging_repo(dir: &Path) -> Repository {
6511        let repo = init_repo(dir);
6512        // A: the shared base on `main`.
6513        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6514        let a_commit = repo.find_commit(a).unwrap();
6515        // origin/main diverges to C, a sibling of the local tip.
6516        let c = empty_commit(&repo, None, &[&a_commit], "C");
6517        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6518            .unwrap();
6519        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6520        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6521        // Release the commit's borrow of `repo` so it can be returned.
6522        drop(a_commit);
6523        repo.set_head("refs/heads/main").unwrap();
6524        // Configure the tracking relationship so `upstream()` resolves.
6525        let mut cfg = repo.config().unwrap();
6526        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6527            .unwrap();
6528        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6529            .unwrap();
6530        cfg.set_str("branch.main.remote", "origin").unwrap();
6531        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6532        repo
6533    }
6534
6535    /// Builds a repo whose `main` has **no upstream configured** but is 1 commit
6536    /// behind a resolvable `origin/main` — the "no own upstream, but behind the
6537    /// default branch" case [`repo_main_behind`] exists for (#1457). No
6538    /// `origin/HEAD` symref is set, so resolution goes through
6539    /// [`RemoteInfo::detect_main_branch_local`]'s common-names fallback.
6540    fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6541        let repo = init_repo(dir);
6542        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6543        let a_commit = repo.find_commit(a).unwrap();
6544        let c = empty_commit(&repo, None, &[&a_commit], "C");
6545        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6546            .unwrap();
6547        drop(a_commit);
6548        repo.set_head("refs/heads/main").unwrap();
6549        repo
6550    }
6551
6552    #[test]
6553    fn git_status_reads_branch_and_ahead_behind() {
6554        let dir = tempfile::tempdir().unwrap();
6555        let _repo = diverging_repo(dir.path());
6556        let status = git_status(dir.path());
6557        assert_eq!(status.branch.as_deref(), Some("main"));
6558        assert_eq!(status.ahead, Some(1));
6559        assert_eq!(status.behind, Some(1));
6560        // A normal checkout names itself and is not flagged a worktree.
6561        assert_eq!(
6562            status.main_repo.as_deref(),
6563            dir.path().file_name().and_then(|n| n.to_str())
6564        );
6565        assert!(!status.is_worktree);
6566    }
6567
6568    #[test]
6569    fn git_status_empty_repo_is_unborn() {
6570        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6571        // branch/sync fields stay empty rather than panicking — but the repo
6572        // identity is still resolved from the common dir.
6573        let dir = tempfile::tempdir().unwrap();
6574        init_repo(dir.path());
6575        let status = git_status(dir.path());
6576        assert_eq!(status.branch, None);
6577        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6578        assert_eq!(status.head_sha, None);
6579        assert_eq!(status.ahead, None);
6580        assert_eq!(status.behind, None);
6581        assert_eq!(
6582            status.main_repo.as_deref(),
6583            dir.path().file_name().and_then(|n| n.to_str())
6584        );
6585        assert!(!status.is_worktree);
6586    }
6587
6588    #[test]
6589    fn git_status_no_upstream_reports_branch_only() {
6590        let dir = tempfile::tempdir().unwrap();
6591        let repo = init_repo(dir.path());
6592        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6593        repo.set_head("refs/heads/main").unwrap();
6594        let status = git_status(dir.path());
6595        assert_eq!(status.branch.as_deref(), Some("main"));
6596        // No upstream → ahead/behind stay absent rather than zero.
6597        assert_eq!(status.ahead, None);
6598        assert_eq!(status.behind, None);
6599        // …and so does the upstream SHA, so such a branch still renders with no
6600        // sync indicator at all (#1344).
6601        assert_eq!(status.upstream_sha, None);
6602    }
6603
6604    #[test]
6605    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6606        // A plain directory that is not a git repo yields nothing at all.
6607        let plain = tempfile::tempdir().unwrap();
6608        assert_eq!(git_status(plain.path()), GitStatus::default());
6609
6610        // A detached HEAD reports no branch (and thus no sync), but the repo
6611        // identity is still resolved from the common dir.
6612        let dir = tempfile::tempdir().unwrap();
6613        let repo = init_repo(dir.path());
6614        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6615        repo.set_head_detached(a).unwrap();
6616        let status = git_status(dir.path());
6617        assert_eq!(status.branch, None);
6618        // A detached HEAD has no branch but *does* have a commit — the SHA is
6619        // resolved before the branch filter, so it survives here (#1337).
6620        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6621        assert_eq!(status.ahead, None);
6622        assert_eq!(status.behind, None);
6623        // A detached HEAD has no branch, so there is no upstream to resolve
6624        // either — the branch filter returns before the wrap (#1344).
6625        assert_eq!(status.upstream_sha, None);
6626        assert_eq!(
6627            status.main_repo.as_deref(),
6628            dir.path().file_name().and_then(|n| n.to_str())
6629        );
6630        assert!(!status.is_worktree);
6631    }
6632
6633    // --- Lazy ahead/behind (#1306) -----------------------------------------
6634
6635    #[test]
6636    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6637        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6638        // the streamed tree snapshot still reads the branch and repo identity, but
6639        // leaves ahead/behind absent — divergence is now lazy (#1306).
6640        let dir = tempfile::tempdir().unwrap();
6641        let repo = diverging_repo(dir.path());
6642        let status = git_status_cheap(dir.path());
6643        assert_eq!(status.branch.as_deref(), Some("main"));
6644        assert_eq!(status.ahead, None);
6645        assert_eq!(status.behind, None);
6646        assert_eq!(
6647            status.main_repo.as_deref(),
6648            dir.path().file_name().and_then(|n| n.to_str())
6649        );
6650        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6651        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6652        let head = repo.head().unwrap().target().unwrap();
6653        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6654    }
6655
6656    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6657
6658    #[test]
6659    fn git_status_head_sha_tracks_new_commits() {
6660        // The regression #1337 turns on: a commit must change the status the
6661        // snapshot is built from. Before the SHA rode the payload, committing
6662        // changed nothing on the wire, the server's diff dropped the identical
6663        // snapshot, and no client re-rendered — so a badge computed for the old
6664        // head survived the push that invalidated it.
6665        let dir = tempfile::tempdir().unwrap();
6666        let repo = init_repo(dir.path());
6667        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6668        repo.set_head("refs/heads/main").unwrap();
6669        let before = git_status_cheap(dir.path());
6670        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6671
6672        let head = repo.find_commit(a).unwrap();
6673        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6674        let after = git_status_cheap(dir.path());
6675        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6676        assert_ne!(before.head_sha, after.head_sha);
6677        // The branch is unchanged — the SHA is the *only* thing that moved, which
6678        // is exactly why its absence made the push invisible.
6679        assert_eq!(before.branch, after.branch);
6680    }
6681
6682    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6683
6684    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6685    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6686    /// these tests exercise a push with no network and no second repo.
6687    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6688        repo.reference("refs/remotes/origin/main", oid, true, "push")
6689            .unwrap();
6690    }
6691
6692    #[test]
6693    fn git_status_upstream_sha_tracks_a_push() {
6694        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6695        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6696        // remote-tracking ref, so before this field rode the payload every wire
6697        // field was byte-identical across the push, the server's diff dropped the
6698        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6699        // never re-asked — the row showed `↑1 ↓0` forever.
6700        let dir = tempfile::tempdir().unwrap();
6701        let repo = diverging_repo(dir.path());
6702        let before = git_status(dir.path());
6703        assert_eq!(before.ahead, Some(1));
6704        assert_eq!(before.behind, Some(1));
6705
6706        let head = repo.head().unwrap().target().unwrap();
6707        simulate_push(&repo, head);
6708        let after = git_status(dir.path());
6709
6710        // The upstream now names the pushed commit, and the counts agree.
6711        assert_eq!(
6712            after.upstream_sha.as_deref(),
6713            Some(head.to_string().as_str())
6714        );
6715        assert_ne!(before.upstream_sha, after.upstream_sha);
6716        assert_eq!(after.ahead, Some(0));
6717        assert_eq!(after.behind, Some(0));
6718        // Nothing else moved — which is the whole point. A push leaves the branch
6719        // and the local head exactly where they were, so `upstream_sha` is the
6720        // only signal a client could possibly notice.
6721        assert_eq!(before.branch, after.branch);
6722        assert_eq!(before.head_sha, after.head_sha);
6723    }
6724
6725    #[test]
6726    fn git_status_cheap_reports_upstream_sha() {
6727        // The crux: the field has to ride the *cheap* path, since that is the one
6728        // the streamed snapshot is built from. Costing a config lookup and a refs
6729        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6730        // walk still absent here.
6731        let dir = tempfile::tempdir().unwrap();
6732        let repo = diverging_repo(dir.path());
6733        let status = git_status_cheap(dir.path());
6734        let upstream = repo
6735            .find_branch("origin/main", git2::BranchType::Remote)
6736            .unwrap()
6737            .get()
6738            .target()
6739            .unwrap();
6740        assert_eq!(
6741            status.upstream_sha.as_deref(),
6742            Some(upstream.to_string().as_str())
6743        );
6744        assert_eq!(status.ahead, None);
6745        assert_eq!(status.behind, None);
6746    }
6747
6748    #[test]
6749    fn folder_divergence_computes_ahead_behind_and_degrades() {
6750        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6751        let dir = tempfile::tempdir().unwrap();
6752        let _repo = diverging_repo(dir.path());
6753        assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6754
6755        // A branch with no upstream → None (the tree renders no sync indicator).
6756        let no_up = tempfile::tempdir().unwrap();
6757        let repo = init_repo(no_up.path());
6758        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6759        repo.set_head("refs/heads/main").unwrap();
6760        assert_eq!(folder_divergence(no_up.path()).ahead_behind, None);
6761
6762        // A detached HEAD and a plain (non-repo) directory → None.
6763        let detached = tempfile::tempdir().unwrap();
6764        let drepo = init_repo(detached.path());
6765        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6766        drepo.set_head_detached(a).unwrap();
6767        assert_eq!(folder_divergence(detached.path()).ahead_behind, None);
6768        let plain = tempfile::tempdir().unwrap();
6769        assert_eq!(folder_divergence(plain.path()).ahead_behind, None);
6770    }
6771
6772    // --- Lazy main-branch behind (#1457) ------------------------------------
6773
6774    #[test]
6775    fn folder_divergence_computes_main_behind_and_degrades() {
6776        // No own upstream, but a resolvable `origin/main` (via the common-names
6777        // fallback — no `origin/HEAD` symref is set) that the branch is
6778        // genuinely behind.
6779        let dir = tempfile::tempdir().unwrap();
6780        let _repo = behind_main_no_upstream_repo(dir.path());
6781        assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
6782
6783        // A detached HEAD and a plain (non-repo) directory → None.
6784        let detached = tempfile::tempdir().unwrap();
6785        let drepo = init_repo(detached.path());
6786        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6787        drepo.set_head_detached(a).unwrap();
6788        assert_eq!(folder_divergence(detached.path()).main_behind, None);
6789        let plain = tempfile::tempdir().unwrap();
6790        assert_eq!(folder_divergence(plain.path()).main_behind, None);
6791    }
6792
6793    #[test]
6794    fn folder_divergence_skips_main_behind_when_own_upstream_is_the_default_branch() {
6795        // `diverging_repo` checks out `main` tracking `origin/main` itself — the
6796        // common case — so even though it's genuinely 1 behind, `main_behind`
6797        // stays silent: `ahead_behind`'s `behind` already reports this
6798        // exact divergence.
6799        let dir = tempfile::tempdir().unwrap();
6800        let _repo = diverging_repo(dir.path());
6801        assert_eq!(folder_divergence(dir.path()).main_behind, None);
6802    }
6803
6804    #[test]
6805    fn folder_divergence_has_no_main_behind_without_a_resolvable_default_branch() {
6806        // No `origin` remote-tracking refs at all (no symref, no common names).
6807        let dir = tempfile::tempdir().unwrap();
6808        let repo = init_repo(dir.path());
6809        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6810        repo.set_head("refs/heads/main").unwrap();
6811        assert_eq!(folder_divergence(dir.path()).main_behind, None);
6812    }
6813
6814    #[test]
6815    fn folder_divergence_reports_independent_main_behind_and_ahead_behind_counts() {
6816        let dir = tempfile::tempdir().unwrap();
6817        let repo = init_repo(dir.path());
6818        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6819        let base_commit = repo.find_commit(base).unwrap();
6820
6821        // origin/main advances 3 commits past the shared base.
6822        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6823        let m1_commit = repo.find_commit(m1).unwrap();
6824        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6825        let m2_commit = repo.find_commit(m2).unwrap();
6826        let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6827        repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6828            .unwrap();
6829
6830        // `feature` branches off the shared base and diverges 1 ahead / 1
6831        // behind its own upstream `origin/feature`.
6832        let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6833        repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6834            .unwrap();
6835        empty_commit(
6836            &repo,
6837            Some("refs/heads/feature"),
6838            &[&base_commit],
6839            "local-feature",
6840        );
6841        drop(base_commit);
6842        drop(m1_commit);
6843        drop(m2_commit);
6844
6845        repo.set_head("refs/heads/feature").unwrap();
6846        let mut cfg = repo.config().unwrap();
6847        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6848            .unwrap();
6849        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6850            .unwrap();
6851        cfg.set_str("branch.feature.remote", "origin").unwrap();
6852        cfg.set_str("branch.feature.merge", "refs/heads/feature")
6853            .unwrap();
6854
6855        assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6856        assert_eq!(folder_divergence(dir.path()).main_behind, Some(3));
6857    }
6858
6859    #[tokio::test]
6860    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6861        let diverging = tempfile::tempdir().unwrap();
6862        let _d = diverging_repo(diverging.path());
6863        let no_up = tempfile::tempdir().unwrap();
6864        let repo = init_repo(no_up.path());
6865        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6866        repo.set_head("refs/heads/main").unwrap();
6867
6868        let svc = WorktreesService::new();
6869        let diverging_path = diverging.path().display().to_string();
6870        let no_up_path = no_up.path().display().to_string();
6871        let reply = svc
6872            .handle(
6873                "ahead-behind",
6874                json!({ "paths": [&diverging_path, &no_up_path] }),
6875            )
6876            .await
6877            .unwrap();
6878        let results = reply.get("results").unwrap();
6879        // The diverging worktree carries its counts, keyed by the requested path.
6880        // `diverging_repo` checks out `main` tracking `origin/main` itself, so
6881        // `main_behind` is skipped — `behind` already reports this divergence.
6882        let d = results.get(diverging_path.as_str()).unwrap();
6883        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6884        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6885        assert!(d.get("main_behind").is_none(), "{d:?}");
6886        // The no-upstream worktree has no `origin` remote-tracking refs at all,
6887        // so neither `ahead`/`behind` nor `main_behind` resolves — the row is
6888        // omitted entirely, not reported as zero.
6889        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6890
6891        // A missing/empty `paths` list yields an empty results object, not an error.
6892        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6893        assert_eq!(empty.get("results"), Some(&json!({})));
6894    }
6895
6896    /// The adapter forwards a daemon-level accept outage to its registry (#2111),
6897    /// which is covered in depth in `crate::worktrees`; here it just has to reach it
6898    /// and leave a live window live.
6899    #[tokio::test]
6900    async fn credit_accept_outage_reaches_the_registry_without_dropping_a_live_window() {
6901        let svc = WorktreesService::new();
6902        svc.handle(
6903            "register",
6904            json!({ "key": "w", "folders": [], "repo": "x" }),
6905        )
6906        .await
6907        .unwrap();
6908
6909        svc.credit_accept_outage(Duration::from_secs(5));
6910
6911        assert_eq!(svc.registry.list().len(), 1);
6912    }
6913
6914    /// Every window asks about the same worktrees at once (#2111): all of those
6915    /// requests must get the right answer, whether they shared a computation or not,
6916    /// including a request that names one worktree twice.
6917    #[tokio::test]
6918    async fn concurrent_ahead_behind_requests_all_get_the_same_correct_answer() {
6919        let dir = tempfile::tempdir().unwrap();
6920        let _repo = diverging_repo(dir.path());
6921        let svc = WorktreesService::new();
6922        let path = dir.path().display().to_string();
6923
6924        let replies = futures::future::join_all(
6925            (0..16).map(|_| svc.handle("ahead-behind", json!({ "paths": [&path, &path] }))),
6926        )
6927        .await;
6928
6929        for reply in replies {
6930            let reply = reply.unwrap();
6931            let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6932            assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
6933            assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
6934        }
6935    }
6936
6937    #[tokio::test]
6938    async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6939        let dir = tempfile::tempdir().unwrap();
6940        let _repo = behind_main_no_upstream_repo(dir.path());
6941        let svc = WorktreesService::new();
6942        let path = dir.path().display().to_string();
6943        let reply = svc
6944            .handle("ahead-behind", json!({ "paths": [&path] }))
6945            .await
6946            .unwrap();
6947        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6948        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6949        // No own upstream at all → no `ahead`/`behind` keys, just `main_behind`.
6950        assert!(entry.get("ahead").is_none(), "{entry:?}");
6951        assert!(entry.get("behind").is_none(), "{entry:?}");
6952    }
6953
6954    #[tokio::test]
6955    async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6956        // `release` stays perfectly in sync with its own upstream
6957        // `origin/release`, while `origin/main` has independently advanced 2
6958        // commits past their shared base — `main_behind` must still fold in
6959        // even though `ahead`/`behind` are both zero.
6960        let dir = tempfile::tempdir().unwrap();
6961        let repo = init_repo(dir.path());
6962        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6963        let base_commit = repo.find_commit(base).unwrap();
6964
6965        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6966        let m1_commit = repo.find_commit(m1).unwrap();
6967        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6968        repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6969            .unwrap();
6970
6971        let r = empty_commit(
6972            &repo,
6973            Some("refs/heads/release"),
6974            &[&base_commit],
6975            "release",
6976        );
6977        repo.reference("refs/remotes/origin/release", r, true, "origin release")
6978            .unwrap();
6979        drop(base_commit);
6980        drop(m1_commit);
6981
6982        repo.set_head("refs/heads/release").unwrap();
6983        let mut cfg = repo.config().unwrap();
6984        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6985            .unwrap();
6986        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6987            .unwrap();
6988        cfg.set_str("branch.release.remote", "origin").unwrap();
6989        cfg.set_str("branch.release.merge", "refs/heads/release")
6990            .unwrap();
6991
6992        let svc = WorktreesService::new();
6993        let path = dir.path().display().to_string();
6994        let reply = svc
6995            .handle("ahead-behind", json!({ "paths": [&path] }))
6996            .await
6997            .unwrap();
6998        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6999        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
7000        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
7001        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7002    }
7003
7004    #[tokio::test]
7005    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
7006        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
7007        let dir = tempfile::tempdir().unwrap();
7008        let _repo = diverging_repo(dir.path());
7009        let svc = WorktreesService::new();
7010        svc.handle(
7011            "register",
7012            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7013        )
7014        .await
7015        .unwrap();
7016
7017        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7018        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7019        let main_wt = &worktrees[0];
7020        // The cheap parts are present, but divergence is not — it is fetched
7021        // lazily via the `ahead-behind` op (#1306).
7022        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
7023        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
7024        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
7025    }
7026
7027    #[tokio::test]
7028    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
7029        // The end-to-end shape of the #1337 freshness fix. The server pushes a
7030        // snapshot only when it differs from the last one
7031        // (`server.rs`: `if snap != last`), so anything invisible on the wire
7032        // cannot trigger a re-render. Committing must therefore move the payload.
7033        let dir = tempfile::tempdir().unwrap();
7034        let repo = init_repo(dir.path());
7035        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7036        repo.set_head("refs/heads/main").unwrap();
7037
7038        let svc = WorktreesService::new();
7039        svc.handle(
7040            "register",
7041            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7042        )
7043        .await
7044        .unwrap();
7045
7046        let before = svc.handle("tree", Value::Null).await.unwrap();
7047        let wt = &repos_of(&before)[0]["worktrees"][0];
7048        assert_eq!(
7049            wt.get("head_sha").and_then(Value::as_str),
7050            Some(a.to_string().as_str())
7051        );
7052
7053        // Commit again: same branch, same paths, same open windows — pre-#1337 the
7054        // snapshot was byte-identical here and the push was dropped.
7055        let head = repo.find_commit(a).unwrap();
7056        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7057        let after = svc.handle("tree", Value::Null).await.unwrap();
7058        assert_eq!(
7059            repos_of(&after)[0]["worktrees"][0]
7060                .get("head_sha")
7061                .and_then(Value::as_str),
7062            Some(b.to_string().as_str())
7063        );
7064        assert_ne!(before, after, "a commit must be a visible snapshot delta");
7065    }
7066
7067    #[tokio::test]
7068    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7069        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
7070        // matching the payload's `skip_serializing_if` convention.
7071        let dir = tempfile::tempdir().unwrap();
7072        init_repo(dir.path());
7073        let svc = WorktreesService::new();
7074        svc.handle(
7075            "register",
7076            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7077        )
7078        .await
7079        .unwrap();
7080        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7081        assert!(wt.get("head_sha").is_none(), "{wt:?}");
7082    }
7083
7084    // --- Upstream SHA on the snapshot (#1344) ------------------------------
7085
7086    #[tokio::test]
7087    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7088        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
7089        // moves neither the branch nor the local head, so `upstream_sha` is the
7090        // only field that can carry the news. Without it the snapshot serialised
7091        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
7092        // window re-rendered, and the lazy ahead/behind was never re-fetched.
7093        let dir = tempfile::tempdir().unwrap();
7094        let repo = diverging_repo(dir.path());
7095        let svc = WorktreesService::new();
7096        svc.handle(
7097            "register",
7098            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7099        )
7100        .await
7101        .unwrap();
7102
7103        let before = svc.handle("tree", Value::Null).await.unwrap();
7104        let head = repo.head().unwrap().target().unwrap();
7105        assert_ne!(
7106            repos_of(&before)[0]["worktrees"][0]
7107                .get("upstream_sha")
7108                .and_then(Value::as_str),
7109            Some(head.to_string().as_str()),
7110            "the fixture must start un-pushed for this to prove anything"
7111        );
7112
7113        // Push: only `refs/remotes/origin/main` moves.
7114        simulate_push(&repo, head);
7115        let after = svc.handle("tree", Value::Null).await.unwrap();
7116        let wt = &repos_of(&after)[0]["worktrees"][0];
7117        assert_eq!(
7118            wt.get("upstream_sha").and_then(Value::as_str),
7119            Some(head.to_string().as_str())
7120        );
7121        // The head and branch are untouched across the push — so this delta rests
7122        // entirely on `upstream_sha`.
7123        assert_eq!(
7124            wt.get("head_sha").and_then(Value::as_str),
7125            repos_of(&before)[0]["worktrees"][0]
7126                .get("head_sha")
7127                .and_then(Value::as_str)
7128        );
7129        assert_ne!(before, after, "a push must be a visible snapshot delta");
7130    }
7131
7132    #[tokio::test]
7133    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7134        // Wire-compat, and the no-regression case: a branch tracking nothing sends
7135        // no key at all rather than a null, so an older client sees exactly the
7136        // payload it saw before and still renders no sync indicator.
7137        let dir = tempfile::tempdir().unwrap();
7138        let repo = init_repo(dir.path());
7139        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7140        repo.set_head("refs/heads/main").unwrap();
7141        let svc = WorktreesService::new();
7142        svc.handle(
7143            "register",
7144            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7145        )
7146        .await
7147        .unwrap();
7148        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7149        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7150        // The head still rides, so this is specifically the upstream degrading.
7151        assert!(wt.get("head_sha").is_some(), "{wt:?}");
7152    }
7153
7154    // --- PR badge poller (#1337) -------------------------------------------
7155
7156    /// Writes an executable stub that ignores its arguments and prints `stdout`,
7157    /// standing in for `gh api graphql` so the poll loop is exercised offline.
7158    /// Returns the shim lock alongside the path: the caller **must** hold the
7159    /// guard until the poller has finished exec'ing the stub. Writing an
7160    /// executable and then `execve`ing it races every other thread that forks —
7161    /// the child inherits the still-open writable FD and the exec fails
7162    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
7163    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7164        let guard = shim_lock();
7165        let path = dir.join("fake-gh");
7166        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7167        (path, guard)
7168    }
7169
7170    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
7171    /// byte to a counter file before printing `stdout`. The returned counter path
7172    /// lets a test assert how many `gh` subprocesses actually ran, independent of
7173    /// the #1387 request-log counter — so the two can be compared (#1389).
7174    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7175        let guard = shim_lock();
7176        let path = dir.join("fake-gh");
7177        let counter = dir.join("gh-calls");
7178        write_exec_script(
7179            &path,
7180            &format!(
7181                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7182                counter = counter.display()
7183            ),
7184        );
7185        (path, guard, counter)
7186    }
7187
7188    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
7189    /// ran) — the length of the counter file.
7190    fn gh_spawn_count(counter: &Path) -> usize {
7191        std::fs::read(counter).map_or(0, |b| b.len())
7192    }
7193
7194    /// The number of **successful** `kind: "gh"` records the #1387 choke point
7195    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
7196    /// on the exit code matches the ground-truth counter (which is written when the
7197    /// stub *runs*), so a rare failed spawn cannot desync the two.
7198    fn counted_gh_records(log: &Path) -> usize {
7199        std::fs::read_to_string(log)
7200            .unwrap_or_default()
7201            .lines()
7202            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7203            .count()
7204    }
7205
7206    /// A repo with a GitHub origin and one commit on `main`.
7207    fn github_repo(dir: &Path) -> Repository {
7208        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7209    }
7210
7211    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
7212    /// test can register **distinct** targets (owner/name) across windows.
7213    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7214        let repo = init_repo(dir);
7215        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7216        repo.set_head("refs/heads/main").unwrap();
7217        repo.remote("origin", url).unwrap();
7218        repo
7219    }
7220
7221    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
7222    /// because the fold downgrades a badge naming a different commit than the
7223    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
7224    /// wrong reason.
7225    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7226        PrBadge {
7227            number,
7228            is_draft: false,
7229            checks: PrCheckState::Pending,
7230            url: "u".into(),
7231            head_oid: head_oid.to_string(),
7232        }
7233    }
7234
7235    /// Wraps a badge as the cache's resolution value (#1370).
7236    fn pr(badge: PrBadge) -> PrResolution {
7237        PrResolution::Pr(badge)
7238    }
7239
7240    #[test]
7241    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7242        let snapshot = json!({"repos":[
7243            {
7244                "main_repo":"omni-dev",
7245                "github":{"owner":"rust-works","name":"omni-dev"},
7246                "root":"/r",
7247                // Enabled (#1376) — a not-polled repo contributes no targets (see
7248                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
7249                "polling_enabled":true,
7250                // Two worktrees on the same branch must ask once, not twice.
7251                "worktrees":[
7252                    {"path":"/r","branch":"main","is_main":true,"open":true},
7253                    {"path":"/w1","branch":"main","is_main":false,"open":true},
7254                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
7255                    // Detached: no branch, so nothing to resolve.
7256                    {"path":"/w3","is_main":false,"open":true}
7257                ]
7258            },
7259            {
7260                // Not on GitHub: contributes no targets at all.
7261                "main_repo":"local","root":"/l",
7262                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7263            }
7264        ]});
7265        let targets = pr_targets_from_snapshot(&snapshot);
7266        assert_eq!(
7267            targets,
7268            vec![
7269                PrTarget {
7270                    owner: "rust-works".into(),
7271                    name: "omni-dev".into(),
7272                    branch: "feature".into()
7273                },
7274                PrTarget {
7275                    owner: "rust-works".into(),
7276                    name: "omni-dev".into(),
7277                    branch: "main".into()
7278                },
7279            ]
7280        );
7281    }
7282
7283    #[test]
7284    fn pr_targets_from_snapshot_is_empty_without_repos() {
7285        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7286        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7287    }
7288
7289    #[test]
7290    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7291        // Defensive: a `github` object without usable owner/name strings yields no
7292        // target rather than a half-built query.
7293        for github in [
7294            json!({}),
7295            json!({"owner": "o"}),
7296            json!({"owner": 1, "name": 2}),
7297        ] {
7298            let snapshot = json!({"repos":[{
7299                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7300                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7301            }]});
7302            assert!(
7303                pr_targets_from_snapshot(&snapshot).is_empty(),
7304                "{snapshot:?}"
7305            );
7306        }
7307    }
7308
7309    #[test]
7310    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7311        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
7312        // absent (the default-off case) or explicitly false contributes no watch,
7313        // so the poll never mentions it. Only an enabled repo is polled.
7314        for repo in [
7315            json!({
7316                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7317                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7318            }),
7319            json!({
7320                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7321                "polling_enabled":false,
7322                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7323            }),
7324        ] {
7325            let snapshot = json!({ "repos": [repo] });
7326            assert!(
7327                pr_targets_from_snapshot(&snapshot).is_empty(),
7328                "not-polled repo must yield no targets: {snapshot:?}"
7329            );
7330        }
7331        // Flipping the same repo to enabled makes it contribute.
7332        let enabled = json!({"repos":[{
7333            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7334            "polling_enabled":true,
7335            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7336        }]});
7337        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7338    }
7339
7340    #[test]
7341    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7342        let backoff = Duration::from_secs(600);
7343        // Never fetched: go.
7344        assert!(pr_should_fetch(false, None, backoff));
7345        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
7346        // spend nothing.
7347        assert!(!pr_should_fetch(
7348            false,
7349            Some(Duration::from_secs(1)),
7350            backoff
7351        ));
7352        // Quiet tree, backoff elapsed: time to look again.
7353        assert!(pr_should_fetch(false, Some(backoff), backoff));
7354        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7355        // The load-bearing case: the watch grew (a target added, or an upstream
7356        // pushed), so fetch **now** regardless of how deep the backoff had grown.
7357        // Without this a push waits out the full ceiling on a stale badge.
7358        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7359        assert!(pr_should_fetch(
7360            true,
7361            Some(Duration::from_millis(1)),
7362            backoff
7363        ));
7364    }
7365
7366    #[test]
7367    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7368        let base = Duration::from_secs(10);
7369        let fresh = Some(Duration::ZERO);
7370        let stale = Some(PENDING_FAST_WINDOW);
7371        // Pending and fresh (within the fast window): hold `base`, however long we
7372        // had backed off for.
7373        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7374        assert_eq!(
7375            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7376            base
7377        );
7378        // Pending but past the fast window (a long CI run): escalate — double up to
7379        // the pending ceiling, never to the terminal one.
7380        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7381        assert_eq!(
7382            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7383            PENDING_MAX_INTERVAL
7384        );
7385        // Pending with nothing having moved yet (`None`) is treated as past the fast
7386        // window, so a stale-from-boot pending state does not pin `base`.
7387        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7388        // Everything terminal: double…
7389        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7390        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7391        // …up to the terminal ceiling (above the pending one), and never overflow.
7392        assert_eq!(
7393            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7394            MAX_PR_POLL_INTERVAL
7395        );
7396        assert_eq!(
7397            next_pr_poll_delay(Duration::MAX, base, false, None),
7398            MAX_PR_POLL_INTERVAL
7399        );
7400    }
7401
7402    /// A watch on one branch with the given upstream tip.
7403    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7404        PrWatch {
7405            target: PrTarget {
7406                owner: "rust-works".into(),
7407                name: "omni-dev".into(),
7408                branch: branch.into(),
7409            },
7410            upstream_sha: upstream.map(str::to_string),
7411        }
7412    }
7413
7414    #[test]
7415    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7416        let a = watch("a", Some("111"));
7417        let b = watch("b", Some("222"));
7418        let ab = [a.clone(), b.clone()];
7419        let just_a = std::slice::from_ref(&a);
7420        let just_b = std::slice::from_ref(&b);
7421        // Nothing new: quiet tick.
7422        assert!(!pr_watch_grew(&ab, &ab));
7423        // Addition: a new target appeared.
7424        assert!(pr_watch_grew(just_a, &ab));
7425        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
7426        assert!(!pr_watch_grew(&ab, just_a));
7427        // First sight (empty prev, from `None`): everything is new.
7428        assert!(pr_watch_grew(&[], just_a));
7429        // A push moves only the upstream — still "grew".
7430        let a_pushed = [watch("a", Some("999"))];
7431        assert!(pr_watch_grew(just_a, &a_pushed));
7432        // Gaining a target while losing another still fetches (the gain wins).
7433        assert!(pr_watch_grew(just_a, just_b));
7434    }
7435
7436    #[test]
7437    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7438        let base = Duration::from_secs(10);
7439        let over = RateLimitSnapshot {
7440            graphql: Some(rl_resource(90)),
7441            core: Some(rl_resource(3)),
7442            search: None,
7443        };
7444        let under = RateLimitSnapshot {
7445            graphql: Some(rl_resource(50)),
7446            core: Some(rl_resource(3)),
7447            search: None,
7448        };
7449        // No reading yet: unchanged.
7450        assert_eq!(budget_throttled_delay(base, None), base);
7451        // Under the warn threshold: unchanged.
7452        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7453        // Over: raised to at least the throttle floor…
7454        assert_eq!(
7455            budget_throttled_delay(base, Some(&over)),
7456            BUDGET_THROTTLE_INTERVAL
7457        );
7458        // …but a delay already above the floor is left alone (never shortened).
7459        let long = BUDGET_THROTTLE_INTERVAL * 2;
7460        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7461    }
7462
7463    #[test]
7464    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7465        // The persisted cache must survive a JSON round trip with `head_oid` intact
7466        // — it is the staleness key the tree wire drops, and losing it would render
7467        // every restored badge stale (#1389, fix 4).
7468        let target = PrTarget {
7469            owner: "rust-works".into(),
7470            name: "omni-dev".into(),
7471            branch: "main".into(),
7472        };
7473        let badge = PrResolution::Pr(PrBadge {
7474            number: 1337,
7475            is_draft: true,
7476            checks: PrCheckState::Pending,
7477            url: "http://x/1337".into(),
7478            head_oid: "deadbeef".into(),
7479        });
7480        let watched = vec![watch("main", Some("abc"))];
7481        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7482            .unwrap()
7483            .with_timezone(&Utc);
7484        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7485
7486        let json = serde_json::to_vec(&prefs).unwrap();
7487        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7488        assert_eq!(back, prefs);
7489        assert_eq!(back.polled_at, Some(polled_at));
7490        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7491        // The restored resolution equals the original — head_oid and all.
7492        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7493    }
7494
7495    #[test]
7496    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7497        // The explicit negative must survive persistence too: restoring `NoPr`
7498        // as "absent" would lose the #1370 distinction across a restart and
7499        // re-ask GitHub for branches already known to have no PR.
7500        let target = PrTarget {
7501            owner: "rust-works".into(),
7502            name: "omni-dev".into(),
7503            branch: "feature".into(),
7504        };
7505        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7506        let json = serde_json::to_vec(&prefs).unwrap();
7507        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7508        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7509        assert_eq!(
7510            back.entries[0].resolution.clone().into_resolution(),
7511            PrResolution::NoPr
7512        );
7513    }
7514
7515    #[test]
7516    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7517        // A file with verdicts but no `polled_at` (an older shape, or a
7518        // hand-edited one) still renders badges, but must not arm the warm
7519        // start: without a poll time there is nothing to age the verdicts
7520        // against, so the poller re-polls immediately (#1389, fix 4).
7521        let dir = tempfile::tempdir().unwrap();
7522        let path = dir.path().join("pr-cache.json");
7523        let target = PrTarget {
7524            owner: "rust-works".into(),
7525            name: "omni-dev".into(),
7526            branch: "main".into(),
7527        };
7528        let mut prefs = pr_cache_prefs_from(
7529            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7530            &[watch("main", None)],
7531            Utc::now(),
7532        );
7533        prefs.polled_at = None;
7534        write_pr_cache(&path, &prefs).unwrap();
7535
7536        let svc = WorktreesService::new();
7537        svc.load_pr_cache(path);
7538        assert!(
7539            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7540            "the badge itself must still restore"
7541        );
7542        assert!(
7543            svc.pr_warm_start
7544                .lock()
7545                .unwrap_or_else(PoisonError::into_inner)
7546                .is_none(),
7547            "no poll time means a cold start, not a trusted warm one"
7548        );
7549    }
7550
7551    /// Installs a thread-local WARN-level subscriber for the duration of a
7552    /// test, so degraded-path `tracing::warn!` sites actually format their
7553    /// fields instead of short-circuiting on "nobody is listening".
7554    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7555        tracing::subscriber::set_default(
7556            tracing_subscriber::fmt()
7557                .with_max_level(tracing::Level::WARN)
7558                .with_writer(std::io::sink)
7559                .finish(),
7560        )
7561    }
7562
7563    #[test]
7564    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7565        // The best-effort contract: a mangled cache is logged and treated as
7566        // empty — never a panic — and the path is stored regardless, so the
7567        // next successful poll rewrites a clean file (#1389, fix 4).
7568        let _trace = warn_subscriber();
7569        let dir = tempfile::tempdir().unwrap();
7570        let corrupt = dir.path().join("pr-cache.json");
7571        std::fs::write(&corrupt, b"not json").unwrap();
7572        let svc = WorktreesService::new();
7573        svc.load_pr_cache(corrupt.clone());
7574        assert!(svc.pr_cache.entries().is_empty());
7575        assert_eq!(
7576            svc.pr_cache_path
7577                .lock()
7578                .unwrap_or_else(PoisonError::into_inner)
7579                .as_deref(),
7580            Some(corrupt.as_path()),
7581            "the path must be stored even when the load fails, so persistence recovers"
7582        );
7583
7584        // A directory: `read` fails with a non-NotFound error (the distinct
7585        // "could not read" arm), with the same treated-as-empty outcome.
7586        let svc = WorktreesService::new();
7587        svc.load_pr_cache(dir.path().to_path_buf());
7588        assert!(svc.pr_cache.entries().is_empty());
7589    }
7590
7591    #[test]
7592    fn persist_pr_cache_swallows_a_write_failure() {
7593        // Best-effort: an unwritable path is logged at WARN and swallowed — the
7594        // in-memory cache stays authoritative, and losing the warm start only
7595        // costs one extra poll after the next restart.
7596        let _trace = warn_subscriber();
7597        let dir = tempfile::tempdir().unwrap();
7598        let blocker = dir.path().join("blocker");
7599        std::fs::write(&blocker, b"").unwrap();
7600        // The parent is a regular file, so creating the runtime dir must fail.
7601        let path = blocker.join("pr-cache.json");
7602        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7603        assert!(!path.exists());
7604        // The root has no parent at all — nothing to create, and the write
7605        // itself fails (it is a directory); still swallowed.
7606        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7607    }
7608
7609    #[tokio::test]
7610    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7611        let dir = tempfile::tempdir().unwrap();
7612        let repo = github_repo(dir.path());
7613        let head = repo.head().unwrap().target().unwrap().to_string();
7614        let svc = WorktreesService::new();
7615        svc.handle(
7616            "register",
7617            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7618        )
7619        .await
7620        .unwrap();
7621        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7622        // act on it, as if the user had toggled it on.
7623        svc.registry.set_polling("rust-works", "omni-dev", true);
7624
7625        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
7626        // and so is the negative, so "not resolved" stays distinguishable (#1370).
7627        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7628        assert!(wt.get("pr").is_none(), "{wt:?}");
7629        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7630
7631        // Seed the cache the poller writes, then re-read the tree.
7632        let mut badges = HashMap::new();
7633        badges.insert(
7634            PrTarget {
7635                owner: "rust-works".into(),
7636                name: "omni-dev".into(),
7637                branch: "main".into(),
7638            },
7639            pr(pending_badge(1337, &head)),
7640        );
7641        assert!(svc.pr_cache.replace(badges));
7642
7643        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7644        assert_eq!(wt["pr"]["number"], json!(1337));
7645        assert_eq!(wt["pr"]["checks"], json!("pending"));
7646        // camelCase on the wire, or the extension silently loses the draft marker.
7647        assert_eq!(wt["pr"]["isDraft"], json!(false));
7648        // A badge and the negative are mutually exclusive.
7649        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7650    }
7651
7652    #[tokio::test]
7653    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7654        // A detached HEAD has a commit but no branch, and a badge is keyed by
7655        // branch — so there is nothing to match. It must fall through silently
7656        // rather than borrow a badge from whatever branch happens to be cached, and
7657        // rather than sink the tree.
7658        let dir = tempfile::tempdir().unwrap();
7659        let repo = github_repo(dir.path());
7660        let head = repo.head().unwrap().target().unwrap();
7661        repo.set_head_detached(head).unwrap();
7662
7663        let svc = WorktreesService::new();
7664        svc.handle(
7665            "register",
7666            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7667        )
7668        .await
7669        .unwrap();
7670        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7671        // act on it, as if the user had toggled it on.
7672        svc.registry.set_polling("rust-works", "omni-dev", true);
7673        // A badge *is* cached for `main` — the branch this worktree was on before
7674        // detaching. It must not leak onto the now-branchless row.
7675        let mut badges = HashMap::new();
7676        badges.insert(
7677            PrTarget {
7678                owner: "rust-works".into(),
7679                name: "omni-dev".into(),
7680                branch: "main".into(),
7681            },
7682            pr(pending_badge(1, &head.to_string())),
7683        );
7684        svc.pr_cache.replace(badges);
7685
7686        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7687        assert!(wt.get("branch").is_none(), "{wt:?}");
7688        // The SHA still shows — detached means no branch, not no commit.
7689        assert_eq!(
7690            wt.get("head_sha").and_then(Value::as_str),
7691            Some(head.to_string().as_str())
7692        );
7693        assert!(wt.get("pr").is_none(), "{wt:?}");
7694        // No branch means nothing was checked either: no negative on the row.
7695        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7696    }
7697
7698    #[tokio::test]
7699    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7700        let dir = tempfile::tempdir().unwrap();
7701        github_repo(dir.path());
7702        let svc = WorktreesService::new();
7703        svc.handle(
7704            "register",
7705            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7706        )
7707        .await
7708        .unwrap();
7709        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7710        // act on it, as if the user had toggled it on.
7711        svc.registry.set_polling("rust-works", "omni-dev", true);
7712        // A badge for a different branch must not leak onto `main`.
7713        let mut badges = HashMap::new();
7714        badges.insert(
7715            PrTarget {
7716                owner: "rust-works".into(),
7717                name: "omni-dev".into(),
7718                branch: "other".into(),
7719            },
7720            pr(pending_badge(1, "irrelevant")),
7721        );
7722        svc.pr_cache.replace(badges);
7723        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7724        assert!(wt.get("pr").is_none(), "{wt:?}");
7725        // An unmatched branch is unresolved, not negative.
7726        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7727    }
7728
7729    #[tokio::test]
7730    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7731        // The #1370 fix on the wire: a branch the poller checked and found PR-less
7732        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
7733        // tell "checked, none" from "not resolved" and keep its fallback quiet.
7734        let dir = tempfile::tempdir().unwrap();
7735        github_repo(dir.path());
7736        let svc = WorktreesService::new();
7737        svc.handle(
7738            "register",
7739            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7740        )
7741        .await
7742        .unwrap();
7743        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7744        // act on it, as if the user had toggled it on.
7745        svc.registry.set_polling("rust-works", "omni-dev", true);
7746
7747        let mut resolutions = HashMap::new();
7748        resolutions.insert(
7749            PrTarget {
7750                owner: "rust-works".into(),
7751                name: "omni-dev".into(),
7752                branch: "main".into(),
7753            },
7754            PrResolution::NoPr,
7755        );
7756        assert!(svc.pr_cache.replace(resolutions));
7757
7758        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7759        assert_eq!(wt["pr_none"], json!(true));
7760        // Mutually exclusive with a badge.
7761        assert!(wt.get("pr").is_none(), "{wt:?}");
7762    }
7763
7764    #[tokio::test]
7765    async fn a_commit_does_not_drop_a_negative_resolution() {
7766        // A negative has no commit to be stale against. Dropping it when HEAD
7767        // moves would flip the row back to "unresolved" on every local commit —
7768        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
7769        // poller's `moved` trigger re-checks the branch promptly instead.
7770        let dir = tempfile::tempdir().unwrap();
7771        let repo = github_repo(dir.path());
7772        let first = repo.head().unwrap().target().unwrap();
7773
7774        let svc = WorktreesService::new();
7775        svc.handle(
7776            "register",
7777            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7778        )
7779        .await
7780        .unwrap();
7781        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7782        // act on it, as if the user had toggled it on.
7783        svc.registry.set_polling("rust-works", "omni-dev", true);
7784
7785        let mut resolutions = HashMap::new();
7786        resolutions.insert(
7787            PrTarget {
7788                owner: "rust-works".into(),
7789                name: "omni-dev".into(),
7790                branch: "main".into(),
7791            },
7792            PrResolution::NoPr,
7793        );
7794        svc.pr_cache.replace(resolutions);
7795
7796        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7797        assert_eq!(wt["pr_none"], json!(true));
7798
7799        // Commit — as a push would leave things, with the cache untouched.
7800        let head = repo.find_commit(first).unwrap();
7801        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7802
7803        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7804        assert_eq!(
7805            wt["pr_none"],
7806            json!(true),
7807            "a local commit must not drop the negative"
7808        );
7809    }
7810
7811    #[tokio::test]
7812    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7813        // The idle case — the daemon runs all day with no editor open. Point it at a
7814        // stub that fails loudly if ever spawned: a poll here would both waste
7815        // GitHub budget and, on a real `gh`, wake the radio for nothing.
7816        let bin_dir = tempfile::tempdir().unwrap();
7817        let marker = bin_dir.path().join("spawned");
7818        let fake = bin_dir.path().join("fake-gh");
7819        std::fs::write(
7820            &fake,
7821            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7822        )
7823        .unwrap();
7824        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7825        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7826        std::fs::set_permissions(&fake, perms).unwrap();
7827
7828        let svc = WorktreesService::new();
7829        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7830        tokio::time::sleep(Duration::from_millis(200)).await;
7831        svc.shutdown().await;
7832        assert!(
7833            !marker.exists(),
7834            "the poller must not spawn gh with no windows registered"
7835        );
7836    }
7837
7838    #[tokio::test]
7839    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7840        // Badges are decoration: an unauthenticated or broken `gh` must never sink
7841        // the tree, and one bad poll must not blank rows that were fine a second ago.
7842        let dir = tempfile::tempdir().unwrap();
7843        let repo = github_repo(dir.path());
7844        let head = repo.head().unwrap().target().unwrap().to_string();
7845        let bin_dir = tempfile::tempdir().unwrap();
7846        let fake = bin_dir.path().join("fake-gh");
7847        // Exits non-zero, exactly as `gh` does without `gh auth login`.
7848        std::fs::write(
7849            &fake,
7850            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7851        )
7852        .unwrap();
7853        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7854        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7855        std::fs::set_permissions(&fake, perms).unwrap();
7856
7857        let svc = WorktreesService::new();
7858        svc.handle(
7859            "register",
7860            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7861        )
7862        .await
7863        .unwrap();
7864        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7865        // act on it, as if the user had toggled it on.
7866        svc.registry.set_polling("rust-works", "omni-dev", true);
7867        // Seed a badge as though an earlier poll had succeeded.
7868        let mut seeded = HashMap::new();
7869        seeded.insert(
7870            PrTarget {
7871                owner: "rust-works".into(),
7872                name: "omni-dev".into(),
7873                branch: "main".into(),
7874            },
7875            pr(pending_badge(7, &head)),
7876        );
7877        svc.pr_cache.replace(seeded);
7878
7879        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7880        tokio::time::sleep(Duration::from_millis(200)).await;
7881
7882        // The tree still serves, and the seeded badge survived the failing polls —
7883        // which also minted no false "no PR" negatives (#1370).
7884        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7885        assert_eq!(wt["pr"]["number"], json!(7));
7886        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7887        svc.shutdown().await;
7888    }
7889
7890    #[tokio::test]
7891    // The shim guard is deliberately held across the awaits below: it must span
7892    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
7893    // is against another test writing while this one forks. Safe here — only test
7894    // threads take it, never a task inside the runtime, so it cannot deadlock.
7895    // Scoped per-test rather than on the module, which would also silence the
7896    // registry lock's "never held across .await" invariant.
7897    #[allow(clippy::await_holding_lock)]
7898    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7899        // The normal startup order: the daemon starts at login, *before* any
7900        // editor. It therefore sees an empty tree and backs off to the 30-minute
7901        // ceiling — so unless a register wakes it, the first badge of the session
7902        // arrives up to half an hour after the window does, which reads as the
7903        // feature being broken rather than slow.
7904        let dir = tempfile::tempdir().unwrap();
7905        github_repo(dir.path());
7906        let bin_dir = tempfile::tempdir().unwrap();
7907        let (fake, _shim) = fake_gh(
7908            bin_dir.path(),
7909            r#"{"data":{"r0":{"b0":{
7910                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7911                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7912                ]}}},
7913                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7914            }}}}"#,
7915        );
7916
7917        let svc = WorktreesService::new();
7918        // Poller first, with nothing registered — it backs off on the empty tree.
7919        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7920        tokio::time::sleep(Duration::from_millis(150)).await;
7921
7922        // Now an editor opens.
7923        svc.handle(
7924            "register",
7925            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7926        )
7927        .await
7928        .unwrap();
7929        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7930        // act on it, as if the user had toggled it on.
7931        svc.registry.set_polling("rust-works", "omni-dev", true);
7932
7933        // The badge must follow promptly — the register wakes the loop out of its
7934        // backoff. The deadline is orders of magnitude below the ceiling, so this
7935        // fails on the bug rather than merely being slow.
7936        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7937            loop {
7938                if let Some(PrResolution::Pr(badge)) =
7939                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7940                {
7941                    return badge;
7942                }
7943                tokio::time::sleep(Duration::from_millis(25)).await;
7944            }
7945        })
7946        .await
7947        .expect("a window opening must wake the poller out of its idle backoff");
7948        assert_eq!(badge.number, 99);
7949        svc.shutdown().await;
7950    }
7951
7952    #[tokio::test]
7953    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7954        // The acceptance criterion: "pushing a new commit invalidates the badge
7955        // rather than leaving the previous head's verdict standing."
7956        //
7957        // The cache still holds the verdict for the *old* commit, and the poller may
7958        // have backed off for up to half an hour. So the fold — which runs on every
7959        // snapshot — has to notice on its own, with no network call.
7960        let dir = tempfile::tempdir().unwrap();
7961        let repo = github_repo(dir.path());
7962        let first = repo.head().unwrap().target().unwrap();
7963
7964        let svc = WorktreesService::new();
7965        svc.handle(
7966            "register",
7967            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7968        )
7969        .await
7970        .unwrap();
7971        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7972        // act on it, as if the user had toggled it on.
7973        svc.registry.set_polling("rust-works", "omni-dev", true);
7974
7975        // A green verdict, correctly describing the commit currently checked out.
7976        let mut badges = HashMap::new();
7977        badges.insert(
7978            PrTarget {
7979                owner: "rust-works".into(),
7980                name: "omni-dev".into(),
7981                branch: "main".into(),
7982            },
7983            pr(PrBadge {
7984                number: 1337,
7985                is_draft: false,
7986                checks: PrCheckState::Success,
7987                url: "u".into(),
7988                head_oid: first.to_string(),
7989            }),
7990        );
7991        svc.pr_cache.replace(badges);
7992
7993        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7994        assert_eq!(
7995            wt["pr"]["checks"],
7996            json!("success"),
7997            "green for its own commit"
7998        );
7999
8000        // Now commit — as a push would leave things, with the cache untouched.
8001        let head = repo.find_commit(first).unwrap();
8002        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8003
8004        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8005        assert_eq!(
8006            wt["pr"]["checks"],
8007            json!("pending"),
8008            "the previous commit's ✓ must not stand after a new commit"
8009        );
8010        // The PR itself is still shown — it is the *verdict* that is unknown, not
8011        // the PR.
8012        assert_eq!(wt["pr"]["number"], json!(1337));
8013    }
8014
8015    #[test]
8016    fn is_stale_for_compares_the_commit_the_verdict_describes() {
8017        let badge = pending_badge(1, "aaa");
8018        assert!(!badge.is_stale_for(Some("aaa")));
8019        assert!(badge.is_stale_for(Some("bbb")));
8020        // No local HEAD (unborn): nothing to compare against, so not stale.
8021        assert!(!badge.is_stale_for(None));
8022    }
8023
8024    #[test]
8025    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
8026        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
8027        // it — so asking would return exactly the cached verdict, and the badge
8028        // stays correctly stale via `is_stale_for` with no network. So a snapshot
8029        // that differs *only* in `head_sha` must compare **equal** as a watch.
8030        let snap = |sha: &str| {
8031            json!({"repos":[{
8032                "main_repo":"omni-dev",
8033                "github":{"owner":"rust-works","name":"omni-dev"},
8034                "root":"/r",
8035                "polling_enabled":true,
8036                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
8037            }]})
8038        };
8039        let before = pr_watch_from_snapshot(&snap("aaa"));
8040        let after = pr_watch_from_snapshot(&snap("bbb"));
8041        assert_eq!(before.len(), 1);
8042        assert_eq!(before[0].target, after[0].target);
8043        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
8044        assert_eq!(before, after);
8045        assert!(!pr_watch_grew(&before, &after));
8046    }
8047
8048    #[test]
8049    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
8050        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
8051        // it moves no local head — so an upstream move alone must register as "go
8052        // and ask now", or the badge sits at `●` until the backoff elapses.
8053        let snap = |upstream: &str| {
8054            json!({"repos":[{
8055                "main_repo":"omni-dev",
8056                "github":{"owner":"rust-works","name":"omni-dev"},
8057                "root":"/r",
8058                "polling_enabled":true,
8059                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
8060                              "upstream_sha":upstream,"is_main":true,"open":true}]
8061            }]})
8062        };
8063        let before = pr_watch_from_snapshot(&snap("aaa"));
8064        let after = pr_watch_from_snapshot(&snap("bbb"));
8065        // Same target, only the upstream moved — a genuine "grew" signal.
8066        assert_eq!(before.len(), 1);
8067        assert_eq!(before[0].target, after[0].target);
8068        assert_ne!(before, after);
8069        assert!(pr_watch_grew(&before, &after));
8070        // A quiet tick still asks nothing.
8071        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8072        assert!(!pr_watch_grew(
8073            &before,
8074            &pr_watch_from_snapshot(&snap("aaa"))
8075        ));
8076    }
8077
8078    #[test]
8079    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8080        // An older daemon — or any branch tracking nothing — simply sends no
8081        // `upstream_sha`, which reads as `None` rather than failing the poll.
8082        let snap = json!({"repos":[{
8083            "main_repo":"omni-dev",
8084            "github":{"owner":"rust-works","name":"omni-dev"},
8085            "root":"/r",
8086            "polling_enabled":true,
8087            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8088        }]});
8089        let watch = pr_watch_from_snapshot(&snap);
8090        assert_eq!(watch.len(), 1);
8091        assert_eq!(watch[0].upstream_sha, None);
8092    }
8093
8094    #[test]
8095    fn start_pr_poller_is_a_noop_outside_a_runtime() {
8096        let svc = WorktreesService::new();
8097        svc.start_pr_poller();
8098        assert!(svc
8099            .poller
8100            .lock()
8101            .unwrap_or_else(PoisonError::into_inner)
8102            .is_none());
8103    }
8104
8105    #[tokio::test]
8106    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8107        let svc = WorktreesService::new();
8108        svc.start_pr_poller_with(
8109            Duration::from_millis(50),
8110            Duration::from_millis(10),
8111            PathBuf::from("/bin/true"),
8112        );
8113        let token = svc
8114            .poller
8115            .lock()
8116            .unwrap_or_else(PoisonError::into_inner)
8117            .as_ref()
8118            .map(|t| t.token.clone())
8119            .expect("poller started");
8120
8121        // Cancel the live task, then start again: if `start` spawned a replacement
8122        // it would orphan this one, so the token staying cancelled proves it did not.
8123        token.cancel();
8124        svc.start_pr_poller_with(
8125            Duration::from_millis(50),
8126            Duration::from_millis(10),
8127            PathBuf::from("/bin/true"),
8128        );
8129        assert!(svc
8130            .poller
8131            .lock()
8132            .unwrap_or_else(PoisonError::into_inner)
8133            .as_ref()
8134            .is_some_and(|t| t.token.is_cancelled()));
8135
8136        svc.shutdown().await;
8137        assert!(svc
8138            .poller
8139            .lock()
8140            .unwrap_or_else(PoisonError::into_inner)
8141            .is_none());
8142    }
8143
8144    // --- Rate-limit monitor (#1375) ---
8145
8146    /// A resource at `used`% of a 100-request budget.
8147    fn rl_resource(used: u64) -> RateLimitResource {
8148        RateLimitResource {
8149            used,
8150            limit: 100,
8151            remaining: 100 - used,
8152            percent: used as f64,
8153            reset: 0,
8154        }
8155    }
8156
8157    #[test]
8158    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8159        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8160            graphql: Some(rl_resource(graphql)),
8161            core: Some(rl_resource(core)),
8162            search: None,
8163        };
8164        // First poll already over threshold → warn.
8165        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8166        // First poll below → no warn.
8167        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8168        // Crossing upward → warn.
8169        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8170        // Staying over → no repeat warn.
8171        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8172        // Recovering below → no warn.
8173        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8174        // A *different* resource crossing while the first recovers is still caught.
8175        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8176    }
8177
8178    #[test]
8179    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8180        let svc = WorktreesService::new();
8181        svc.start_rate_limit_poller();
8182        assert!(svc
8183            .rate_limit_poller
8184            .lock()
8185            .unwrap_or_else(PoisonError::into_inner)
8186            .is_none());
8187    }
8188
8189    #[tokio::test]
8190    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8191        let svc = WorktreesService::new();
8192        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8193        let token = svc
8194            .rate_limit_poller
8195            .lock()
8196            .unwrap_or_else(PoisonError::into_inner)
8197            .as_ref()
8198            .map(|t| t.token.clone())
8199            .expect("poller started");
8200
8201        // Cancel the live task, then start again: a second start must not spawn a
8202        // replacement (which would orphan this one), so the token stays cancelled.
8203        token.cancel();
8204        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8205        assert!(svc
8206            .rate_limit_poller
8207            .lock()
8208            .unwrap_or_else(PoisonError::into_inner)
8209            .as_ref()
8210            .is_some_and(|t| t.token.is_cancelled()));
8211
8212        svc.shutdown().await;
8213        assert!(svc
8214            .rate_limit_poller
8215            .lock()
8216            .unwrap_or_else(PoisonError::into_inner)
8217            .is_none());
8218    }
8219
8220    #[tokio::test]
8221    // Holds the shim guard across awaits; see the note above.
8222    #[allow(clippy::await_holding_lock)]
8223    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8224        let bin_dir = tempfile::tempdir().unwrap();
8225        let (fake, _shim) = fake_gh(
8226            bin_dir.path(),
8227            r#"{"resources":{
8228                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8229                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8230            }}"#,
8231        );
8232        let svc = WorktreesService::new();
8233        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
8234        // is being watched — a lease makes it active without needing a window/folder.
8235        svc.registry.set_polling("rust-works", "omni-dev", true);
8236        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8237
8238        // Each poll spawns a real subprocess; wait on a generous deadline so a
8239        // loaded machine fails honestly rather than flaking.
8240        let snap = tokio::time::timeout(Duration::from_secs(30), async {
8241            loop {
8242                if let Some(snap) = svc.rate_limit_cache.get() {
8243                    return snap;
8244                }
8245                tokio::time::sleep(Duration::from_millis(25)).await;
8246            }
8247        })
8248        .await
8249        .expect("poller should populate the cache through the fake gh");
8250        assert_eq!(snap.graphql.unwrap().used, 4100);
8251        assert_eq!(snap.core.unwrap().used, 27);
8252
8253        // The reading reaches the built-in status field via the shared cache.
8254        assert!(svc.rate_limit_cache().get().is_some());
8255
8256        svc.shutdown().await;
8257        assert!(svc
8258            .rate_limit_poller
8259            .lock()
8260            .unwrap_or_else(PoisonError::into_inner)
8261            .is_none());
8262    }
8263
8264    #[tokio::test]
8265    // Holds the shim guard across awaits; see the note above.
8266    #[allow(clippy::await_holding_lock)]
8267    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8268        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
8269        // `/rate_limit` subprocess — the counting stub records zero spawns.
8270        let bin_dir = tempfile::tempdir().unwrap();
8271        let (fake, _shim, counter) = counting_fake_gh(
8272            bin_dir.path(),
8273            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8274        );
8275        let svc = WorktreesService::new();
8276        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8277
8278        // Give the loop several ticks; with nothing registered it must never poll.
8279        tokio::time::sleep(Duration::from_millis(300)).await;
8280        assert_eq!(
8281            gh_spawn_count(&counter),
8282            0,
8283            "idle daemon must not poll (#1389, fix 8b)"
8284        );
8285        assert!(svc.rate_limit_cache.get().is_none());
8286
8287        // Once a lease is active, the next tick populates the cache.
8288        svc.registry.set_polling("rust-works", "omni-dev", true);
8289        tokio::time::timeout(Duration::from_secs(30), async {
8290            loop {
8291                if svc.rate_limit_cache.get().is_some() {
8292                    return;
8293                }
8294                tokio::time::sleep(Duration::from_millis(25)).await;
8295            }
8296        })
8297        .await
8298        .expect("an active lease should resume polling");
8299        assert!(gh_spawn_count(&counter) >= 1);
8300        svc.shutdown().await;
8301    }
8302
8303    #[tokio::test]
8304    async fn rate_limit_poller_survives_a_failing_gh() {
8305        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
8306        // the degraded branch keeps the last (here, absent) reading rather than
8307        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
8308        let svc = WorktreesService::new();
8309        svc.registry.set_polling("rust-works", "omni-dev", true);
8310        svc.start_rate_limit_poller_with(
8311            Duration::from_millis(20),
8312            PathBuf::from("/no/such/gh/xyzzy"),
8313        );
8314        // Let it fail a few times: the cache stays empty and the task stays alive.
8315        tokio::time::sleep(Duration::from_millis(150)).await;
8316        assert!(svc.rate_limit_cache.get().is_none());
8317        assert!(
8318            svc.rate_limit_poller
8319                .lock()
8320                .unwrap_or_else(PoisonError::into_inner)
8321                .is_some(),
8322            "the loop must survive a failing gh, not panic out"
8323        );
8324        svc.shutdown().await;
8325    }
8326
8327    #[test]
8328    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8329        let svc = WorktreesService::new();
8330        // Empty cache → no rate-limit line (the pre-#1375 shape).
8331        let items = svc.menu().items;
8332        assert!(
8333            !items
8334                .iter()
8335                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8336            "no github line before the first poll"
8337        );
8338
8339        // Populate the cache → the first item is the rate-limit status line.
8340        svc.rate_limit_cache.replace(RateLimitSnapshot {
8341            graphql: Some(rl_resource(82)),
8342            core: Some(rl_resource(3)),
8343            search: None,
8344        });
8345        let items = svc.menu().items;
8346        assert!(
8347            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8348            "expected the github line first, got {items:?}"
8349        );
8350        assert!(
8351            matches!(items.get(1), Some(MenuItem::Separator)),
8352            "expected a separator after the github line"
8353        );
8354    }
8355
8356    #[tokio::test]
8357    // Holds the shim guard across awaits; see the note above.
8358    #[allow(clippy::await_holding_lock)]
8359    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8360        let dir = tempfile::tempdir().unwrap();
8361        github_repo(dir.path());
8362        let bin_dir = tempfile::tempdir().unwrap();
8363        // One repo, one branch → aliases r0/b0. A still-running check so the badge
8364        // stays pending and the loop keeps its fast cadence.
8365        let (fake, _shim) = fake_gh(
8366            bin_dir.path(),
8367            r#"{"data":{"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        let svc = WorktreesService::new();
8375        svc.handle(
8376            "register",
8377            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8378        )
8379        .await
8380        .unwrap();
8381        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8382        // act on it, as if the user had toggled it on.
8383        svc.registry.set_polling("rust-works", "omni-dev", true);
8384        svc.start_pr_poller_with(
8385            Duration::from_millis(50),
8386            Duration::from_millis(10),
8387            fake.clone(),
8388        );
8389
8390        // Wait on a generous wall-clock deadline: each poll spawns a real
8391        // subprocess, and under a loaded machine (a full `build.sh` runs a build
8392        // and clippy alongside) a tight budget flakes rather than fails honestly.
8393        let badge = tokio::time::timeout(Duration::from_secs(30), async {
8394            loop {
8395                if let Some(PrResolution::Pr(badge)) =
8396                    svc.pr_cache.get("rust-works", "omni-dev", "main")
8397                {
8398                    return badge;
8399                }
8400                tokio::time::sleep(Duration::from_millis(25)).await;
8401            }
8402        })
8403        .await
8404        .expect("poller should resolve a badge through the fake gh");
8405        assert_eq!(badge.number, 1337);
8406        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8407
8408        // The badge reaches the wire the windows actually read.
8409        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8410        assert_eq!(wt["pr"]["number"], json!(1337));
8411
8412        // And the loop is quiescent after shutdown: the generation must stop moving.
8413        svc.shutdown().await;
8414        let generation = svc.registry.change_generation();
8415        tokio::time::sleep(Duration::from_millis(120)).await;
8416        assert_eq!(
8417            svc.registry.change_generation(),
8418            generation,
8419            "no bumps after shutdown"
8420        );
8421    }
8422
8423    #[tokio::test]
8424    // Holds the shim guard across awaits; see the note above.
8425    #[allow(clippy::await_holding_lock)]
8426    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8427        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
8428        // the poller folds into the shared cache — so the graphql figure stays fresh
8429        // without a standalone `/rate_limit` call.
8430        let dir = tempfile::tempdir().unwrap();
8431        github_repo(dir.path());
8432        let bin_dir = tempfile::tempdir().unwrap();
8433        let (fake, _shim) = fake_gh(
8434            bin_dir.path(),
8435            r#"{"data":{
8436                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8437                             "resetAt":"2026-07-21T16:00:00Z"},
8438                "r0":{"b0":{
8439                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8440                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8441                  ]}}},
8442                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8443                }}
8444            }}"#,
8445        );
8446        let svc = WorktreesService::new();
8447        svc.handle(
8448            "register",
8449            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8450        )
8451        .await
8452        .unwrap();
8453        svc.registry.set_polling("rust-works", "omni-dev", true);
8454        // No rate-limit poller started: the only writer of the cache is the PR poll's
8455        // folded-in budget, so a populated graphql reading proves fix 8a.
8456        svc.start_pr_poller_with(
8457            Duration::from_millis(50),
8458            Duration::from_millis(10),
8459            fake.clone(),
8460        );
8461
8462        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8463            loop {
8464                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8465                    return g;
8466                }
8467                tokio::time::sleep(Duration::from_millis(25)).await;
8468            }
8469        })
8470        .await
8471        .expect("the PR poll should fold its budget into the cache");
8472        assert_eq!(graphql.used, 123);
8473        assert_eq!(graphql.limit, 5000);
8474        assert_eq!(graphql.remaining, 4877);
8475        svc.shutdown().await;
8476    }
8477
8478    #[tokio::test]
8479    // Holds the shim guard across awaits; see the note above.
8480    #[allow(clippy::await_holding_lock)]
8481    async fn pr_poll_counts_every_gh_call_exactly_once() {
8482        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
8483        // still counted. Compares the ground-truth number of `gh` subprocesses the
8484        // poll actually spawned against the number of successful `kind:"gh"` records
8485        // the counted `run_gh` choke point wrote — they must be equal, so a future
8486        // refactor cannot add an uncounted `gh` path (counted < spawns) without
8487        // failing here.
8488        let dir = tempfile::tempdir().unwrap();
8489        github_repo(dir.path());
8490        let bin_dir = tempfile::tempdir().unwrap();
8491        let (fake, _shim, counter) = counting_fake_gh(
8492            bin_dir.path(),
8493            r#"{"data":{"r0":{"b0":{
8494                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8495                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8496                ]}}},
8497                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8498            }}}}"#,
8499        );
8500        let log = bin_dir.path().join("log.jsonl");
8501        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8502
8503        let svc = WorktreesService::new();
8504        svc.handle(
8505            "register",
8506            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8507        )
8508        .await
8509        .unwrap();
8510        svc.registry.set_polling("rust-works", "omni-dev", true);
8511        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8512
8513        // Wait for at least one fetch to land.
8514        tokio::time::timeout(Duration::from_secs(30), async {
8515            loop {
8516                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8517                    return;
8518                }
8519                tokio::time::sleep(Duration::from_millis(25)).await;
8520            }
8521        })
8522        .await
8523        .expect("poller should fetch through the fake gh");
8524
8525        // Stop the loop so both counts are final (no in-flight gh), then compare.
8526        svc.shutdown().await;
8527        let spawns = gh_spawn_count(&counter);
8528        let counted = counted_gh_records(&log);
8529        std::env::remove_var("OMNI_DEV_LOG_FILE");
8530        assert!(
8531            spawns >= 1,
8532            "the poll should have spent at least one gh call"
8533        );
8534        assert_eq!(
8535            counted, spawns,
8536            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8537        );
8538    }
8539
8540    #[tokio::test]
8541    // Holds the shim guard across awaits; see the note above.
8542    #[allow(clippy::await_holding_lock)]
8543    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8544        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
8545        // its windows) that each *grow* the watch must collapse into ONE fetch on
8546        // the final set, not one per window. Two distinct repos appear back-to-back
8547        // inside the debounce window; a debounce-free loop would fetch twice.
8548        let dir_a = tempfile::tempdir().unwrap();
8549        let dir_b = tempfile::tempdir().unwrap();
8550        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
8551        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
8552        let bin_dir = tempfile::tempdir().unwrap();
8553        // Both terminal, so no fast pending cadence can add a second fetch.
8554        let (fake, _shim, counter) = counting_fake_gh(
8555            bin_dir.path(),
8556            r#"{"data":{
8557                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8558                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8559                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8560                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8561                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8562                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8563            }}"#,
8564        );
8565        let svc = WorktreesService::new();
8566        // Enable polling for both before they register, so the first snapshot after
8567        // the storm already counts them.
8568        svc.registry.set_polling("rust-works", "omni-dev", true);
8569        svc.registry.set_polling("rust-works", "other-repo", true);
8570        // `base` far larger than the test so only the storm — never the cadence —
8571        // can trigger a fetch; a generous debounce so the two registers land inside
8572        // one settle window even on a loaded machine.
8573        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8574        // The burst: both windows register back-to-back.
8575        svc.handle(
8576            "register",
8577            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8578        )
8579        .await
8580        .unwrap();
8581        // A beat between the two, so the first bump has (all but certainly)
8582        // woken the poller into its settle window before the second arrives —
8583        // exercising the debounce *restart*, not just a single coalesced wake.
8584        tokio::time::sleep(Duration::from_millis(50)).await;
8585        svc.handle(
8586            "register",
8587            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8588        )
8589        .await
8590        .unwrap();
8591
8592        // Wait until both badges resolve — proving the single fetch covered the full
8593        // final set, not just the first window.
8594        tokio::time::timeout(Duration::from_secs(30), async {
8595            loop {
8596                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8597                let b = svc
8598                    .pr_cache
8599                    .get("rust-works", "other-repo", "main")
8600                    .is_some();
8601                if a && b {
8602                    return;
8603                }
8604                tokio::time::sleep(Duration::from_millis(25)).await;
8605            }
8606        })
8607        .await
8608        .expect("the debounced fetch should resolve both repos");
8609
8610        svc.shutdown().await;
8611        assert_eq!(
8612            gh_spawn_count(&counter),
8613            1,
8614            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8615        );
8616    }
8617
8618    #[tokio::test]
8619    // Holds the shim guard across awaits; see the note above.
8620    #[allow(clippy::await_holding_lock)]
8621    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8622        // The settle loop is bounded: a drip of registry bumps, each landing
8623        // inside the debounce window, must not postpone the poll forever — the
8624        // overall deadline (4× the debounce) forces the snapshot mid-storm
8625        // (#1389, fix 2).
8626        let dir = tempfile::tempdir().unwrap();
8627        github_repo(dir.path());
8628        let bin_dir = tempfile::tempdir().unwrap();
8629        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8630        let svc = WorktreesService::new();
8631        svc.registry.set_polling("rust-works", "omni-dev", true);
8632        // `base` far past the timeout below, so only the grew-trigger — released by
8633        // the deadline — can fetch; a base the drip could outlive would let the
8634        // periodic cadence satisfy the assertion on a very slow run (#1426).
8635        svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8636        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8637        svc.handle("register", register.clone()).await.unwrap();
8638        // Re-register (an upsert, but still a bump) every 25ms — inside the 50ms
8639        // debounce — until the deadline-forced fetch actually lands. The observation
8640        // point is the condition itself, never a clock: sampling the counter at a
8641        // fixed instant would additionally assert that a `fork` + `exec` + `/bin/sh`
8642        // startup + file append all beat that instant, which is what made this flaky
8643        // under full-suite load (#1426). Under load the drip just runs longer. Every
8644        // iteration bumps, so the fetch is still observed *while bumps are arriving*
8645        // — a deadline-free settle loop never fetches here at all, and the timeout
8646        // *is* the assertion.
8647        let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8648            loop {
8649                tokio::time::sleep(Duration::from_millis(25)).await;
8650                svc.handle("register", register.clone()).await.unwrap();
8651                if gh_spawn_count(&counter) >= 1 {
8652                    return;
8653                }
8654            }
8655        })
8656        .await;
8657        svc.shutdown().await;
8658        forced_mid_drip.expect(
8659            "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
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_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8667        // #1389, fix 4: a daemon restart within the backoff window serves badges
8668        // from the persisted cache and spends **no** gh call, because every current
8669        // target already has a fresh verdict.
8670        let dir = tempfile::tempdir().unwrap();
8671        let repo = github_repo(dir.path());
8672        let head = repo.head().unwrap().target().unwrap().to_string();
8673        let bin_dir = tempfile::tempdir().unwrap();
8674        // If the poller wrongly fetched, this empty reply would still spawn the stub
8675        // and bump the counter — which is exactly what the assertion catches.
8676        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8677
8678        // Persist a fresh cache the way the previous daemon would have: a badge for
8679        // `main` whose verdict is about the current head (so it is not stale),
8680        // watched at `(main, no upstream)`, resolved just now.
8681        let cache_path = bin_dir.path().join("pr-cache.json");
8682        let target = PrTarget {
8683            owner: "rust-works".into(),
8684            name: "omni-dev".into(),
8685            branch: "main".into(),
8686        };
8687        let prefs = pr_cache_prefs_from(
8688            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8689            &[watch("main", None)],
8690            Utc::now(),
8691        );
8692        write_pr_cache(&cache_path, &prefs).unwrap();
8693
8694        let svc = WorktreesService::new();
8695        svc.load_pr_cache(cache_path);
8696        svc.registry.set_polling("rust-works", "omni-dev", true);
8697        svc.handle(
8698            "register",
8699            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8700        )
8701        .await
8702        .unwrap();
8703        // `base` far larger than the test: the only fetch that could happen is the
8704        // immediate one we expect the warm cache to skip.
8705        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8706
8707        // The restored badge renders on the wire without a gh call.
8708        let number = tokio::time::timeout(Duration::from_secs(30), async {
8709            loop {
8710                let tree = svc.handle("tree", Value::Null).await.unwrap();
8711                if let Some(n) = repos_of(&tree)
8712                    .first()
8713                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8714                {
8715                    return n;
8716                }
8717                tokio::time::sleep(Duration::from_millis(25)).await;
8718            }
8719        })
8720        .await
8721        .expect("the restored badge should render from the warm cache");
8722        assert_eq!(number, 1337);
8723
8724        // Let the poller run a while, then confirm it stayed quiet.
8725        tokio::time::sleep(Duration::from_millis(300)).await;
8726        svc.shutdown().await;
8727        assert_eq!(
8728            gh_spawn_count(&counter),
8729            0,
8730            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8731        );
8732    }
8733
8734    #[tokio::test]
8735    // Holds the shim guard across awaits; see the note above.
8736    #[allow(clippy::await_holding_lock)]
8737    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8738        // #1389, fix 4, write side (the twin of the skip test above, which reads
8739        // a hand-written file): a successful resolve persists the cache —
8740        // creating the runtime dir if needed — so the *next* daemon restart
8741        // warm-starts from it.
8742        let dir = tempfile::tempdir().unwrap();
8743        github_repo(dir.path());
8744        let bin_dir = tempfile::tempdir().unwrap();
8745        let (fake, _shim) = fake_gh(
8746            bin_dir.path(),
8747            r#"{"data":{"r0":{"b0":{
8748                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8749                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8750                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8751            }}}}"#,
8752        );
8753        let svc = WorktreesService::new();
8754        // No file yet, and no parent dir either: the load takes the benign
8755        // NotFound arm, and the write must create the `0700` runtime dir.
8756        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8757        svc.load_pr_cache(cache_path.clone());
8758        svc.registry.set_polling("rust-works", "omni-dev", true);
8759        svc.handle(
8760            "register",
8761            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8762        )
8763        .await
8764        .unwrap();
8765        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8766
8767        // A partially-written or not-yet-written file simply retries: only a
8768        // fully parseable cache ends the wait.
8769        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8770            loop {
8771                if let Ok(bytes) = std::fs::read(&cache_path) {
8772                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8773                        if !prefs.entries.is_empty() {
8774                            return prefs;
8775                        }
8776                    }
8777                }
8778                tokio::time::sleep(Duration::from_millis(25)).await;
8779            }
8780        })
8781        .await
8782        .expect("a successful resolve should persist the cache file");
8783        svc.shutdown().await;
8784
8785        assert_eq!(prefs.entries[0].target.branch, "main");
8786        assert!(
8787            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8788            "{:?}",
8789            prefs.entries[0].resolution
8790        );
8791        assert_eq!(
8792            prefs.watched,
8793            vec![PersistedWatch {
8794                target: prefs.entries[0].target.clone(),
8795                upstream_sha: None
8796            }]
8797        );
8798        assert!(
8799            prefs.polled_at.is_some(),
8800            "the poll time is what ages the next warm start"
8801        );
8802    }
8803
8804    #[tokio::test]
8805    // Holds the shim guard across awaits; see the note above.
8806    #[allow(clippy::await_holding_lock)]
8807    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8808        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
8809        // to one counted `gh pr list` per repo. A generous TTL, so the second call
8810        // is served from the cache and spawns **no** second `gh` — the whole point.
8811        let bin_dir = tempfile::tempdir().unwrap();
8812        let (fake, _shim, counter) = counting_fake_gh(
8813            bin_dir.path(),
8814            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8815                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8816        );
8817        let svc = WorktreesService::new();
8818
8819        let prs = svc
8820            .open_prs_with("rust-works", "omni-dev", fake.clone())
8821            .await
8822            .expect("gh pr list should resolve");
8823        assert_eq!(prs.len(), 1);
8824        assert_eq!(prs[0]["number"], json!(42));
8825        assert_eq!(prs[0]["url"], json!("http://x/42"));
8826        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8827
8828        // A second window asking the same repo is served from the shared cache.
8829        let again = svc
8830            .open_prs_with("rust-works", "omni-dev", fake.clone())
8831            .await
8832            .expect("cache hit should resolve");
8833        assert_eq!(again, prs);
8834        assert_eq!(
8835            gh_spawn_count(&counter),
8836            1,
8837            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8838        );
8839
8840        // The op wrapper shapes the reply and validates the payload.
8841        let reply = svc
8842            .handle(
8843                "open-prs",
8844                json!({ "owner": "rust-works", "name": "omni-dev" }),
8845            )
8846            .await
8847            .expect("open-prs op should route");
8848        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8849        assert!(svc
8850            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
8851            .await
8852            .is_err());
8853    }
8854
8855    #[test]
8856    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8857        // The three degraded shapes a real `gh` presents — not installed, a
8858        // nonzero exit (auth/network), and output that is not the JSON array
8859        // the menu indexes into — must each be a distinct, actionable error
8860        // rather than a panic or a silently empty list (#1389, fix 7).
8861        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8862        assert!(
8863            err.to_string().contains("is the GitHub CLI installed"),
8864            "{err:#}"
8865        );
8866
8867        let bin_dir = tempfile::tempdir().unwrap();
8868        let _guard = shim_lock();
8869        let failing = bin_dir.path().join("fake-gh-fails");
8870        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8871        let err = retry_on_etxtbsy(|| open_pr_list(&failing, "rust-works/omni-dev")).unwrap_err();
8872        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8873        assert!(err.to_string().contains("boom"), "{err:#}");
8874
8875        let object = bin_dir.path().join("fake-gh-object");
8876        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8877        let err = retry_on_etxtbsy(|| open_pr_list(&object, "rust-works/omni-dev")).unwrap_err();
8878        assert!(
8879            err.to_string().contains("did not return a JSON array"),
8880            "{err:#}"
8881        );
8882    }
8883
8884    #[tokio::test]
8885    // Holds the shim guard across awaits; see the note above.
8886    #[allow(clippy::await_holding_lock)]
8887    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8888        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
8889        // cadence and ignores even a grown watch, so no runaway can drain the shared
8890        // budget. A recent warm `last_poll` is seeded so the "first sight always
8891        // fetches" base case cannot mask the throttle — the only thing that could
8892        // fetch here is the grew-trigger, which the throttle suppresses.
8893        let dir = tempfile::tempdir().unwrap();
8894        github_repo(dir.path());
8895        let bin_dir = tempfile::tempdir().unwrap();
8896        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8897
8898        let svc = WorktreesService::new();
8899        // Warm start with an *empty* watch but a fresh poll time: the registered
8900        // repo then reads as a grown watch, while `last_poll` is recent enough that
8901        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
8902        *svc.pr_warm_start
8903            .lock()
8904            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8905            watched: vec![],
8906            polled_at: Utc::now(),
8907        });
8908        // Budget over the warn threshold before the poller starts.
8909        svc.rate_limit_cache.replace(RateLimitSnapshot {
8910            graphql: Some(rl_resource(90)),
8911            core: Some(rl_resource(3)),
8912            search: None,
8913        });
8914        svc.registry.set_polling("rust-works", "omni-dev", true);
8915        svc.handle(
8916            "register",
8917            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8918        )
8919        .await
8920        .unwrap();
8921        // `base` far larger than the test, so a fetch could only come from the
8922        // grew-trigger the throttle is meant to suppress.
8923        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8924
8925        // Let the poller wake on the registration and run a while.
8926        tokio::time::sleep(Duration::from_millis(300)).await;
8927        svc.shutdown().await;
8928        assert_eq!(
8929            gh_spawn_count(&counter),
8930            0,
8931            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8932        );
8933    }
8934
8935    #[tokio::test]
8936    // Holds the shim guard across awaits; see the note above.
8937    #[allow(clippy::await_holding_lock)]
8938    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8939        // The diff-and-drop contract: an unchanged poll must not bump, or every
8940        // window re-renders on every tick — the cost this design exists to avoid.
8941        let dir = tempfile::tempdir().unwrap();
8942        github_repo(dir.path());
8943        let bin_dir = tempfile::tempdir().unwrap();
8944        let (fake, _shim) = fake_gh(
8945            bin_dir.path(),
8946            r#"{"data":{"r0":{"b0":{
8947                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8948                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8949                ]}}},
8950                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8951            }}}}"#,
8952        );
8953        let svc = WorktreesService::new();
8954        svc.handle(
8955            "register",
8956            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8957        )
8958        .await
8959        .unwrap();
8960        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8961        // act on it, as if the user had toggled it on.
8962        svc.registry.set_polling("rust-works", "omni-dev", true);
8963        svc.start_pr_poller_with(
8964            Duration::from_millis(50),
8965            Duration::from_millis(10),
8966            fake.clone(),
8967        );
8968
8969        tokio::time::timeout(Duration::from_secs(30), async {
8970            loop {
8971                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8972                    return;
8973                }
8974                tokio::time::sleep(Duration::from_millis(25)).await;
8975            }
8976        })
8977        .await
8978        .expect("poller should resolve a badge through the fake gh");
8979        // The fake always answers identically, so after the first resolve every
8980        // subsequent poll is a no-change and must leave the generation alone.
8981        let settled = svc.registry.change_generation();
8982        tokio::time::sleep(Duration::from_millis(150)).await;
8983        assert_eq!(
8984            svc.registry.change_generation(),
8985            settled,
8986            "an unchanged poll must not bump the change-notify"
8987        );
8988        svc.shutdown().await;
8989    }
8990
8991    #[tokio::test]
8992    // Holds the shim guard across awaits; see the note above.
8993    #[allow(clippy::await_holding_lock)]
8994    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8995        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
8996        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
8997        // as `pr_none`, and — since the answer never changes — bumps the
8998        // change-notify only for the poll that first delivered it.
8999        let dir = tempfile::tempdir().unwrap();
9000        github_repo(dir.path());
9001        let bin_dir = tempfile::tempdir().unwrap();
9002        let (fake, _shim) = fake_gh(
9003            bin_dir.path(),
9004            r#"{"data":{"r0":{"b0":{
9005                "target":{"oid":"abc","statusCheckRollup":null},
9006                "associatedPullRequests":{"nodes":[]}
9007            }}}}"#,
9008        );
9009        let svc = WorktreesService::new();
9010        svc.handle(
9011            "register",
9012            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9013        )
9014        .await
9015        .unwrap();
9016        // Polling defaults off (#1376): enable it for this repo so the poller/fold
9017        // act on it, as if the user had toggled it on.
9018        svc.registry.set_polling("rust-works", "omni-dev", true);
9019        svc.start_pr_poller_with(
9020            Duration::from_millis(50),
9021            Duration::from_millis(10),
9022            fake.clone(),
9023        );
9024
9025        tokio::time::timeout(Duration::from_secs(30), async {
9026            loop {
9027                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
9028                    return;
9029                }
9030                tokio::time::sleep(Duration::from_millis(25)).await;
9031            }
9032        })
9033        .await
9034        .expect("poller should resolve the negative through the fake gh");
9035
9036        // The negative reaches the wire the windows actually read.
9037        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9038        assert_eq!(wt["pr_none"], json!(true));
9039        assert!(wt.get("pr").is_none(), "{wt:?}");
9040
9041        // Identical re-polls of the same negative must not bump.
9042        let settled = svc.registry.change_generation();
9043        tokio::time::sleep(Duration::from_millis(150)).await;
9044        assert_eq!(
9045            svc.registry.change_generation(),
9046            settled,
9047            "an unchanged negative must not bump the change-notify"
9048        );
9049        svc.shutdown().await;
9050    }
9051
9052    #[test]
9053    fn sync_indicator_formats_only_with_upstream() {
9054        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
9055        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
9056        assert_eq!(sync_indicator(None, None), None);
9057        // A partial pair (no real upstream) yields nothing.
9058        assert_eq!(sync_indicator(Some(1), None), None);
9059    }
9060
9061    #[tokio::test]
9062    async fn list_enriches_entries_with_git_status() {
9063        let dir = tempfile::tempdir().unwrap();
9064        let repo = init_repo(dir.path());
9065        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9066        repo.set_head("refs/heads/main").unwrap();
9067
9068        let svc = WorktreesService::new();
9069        svc.handle(
9070            "register",
9071            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9072        )
9073        .await
9074        .unwrap();
9075        let payload = svc.handle("list", Value::Null).await.unwrap();
9076        let windows = windows_of(&payload);
9077        assert_eq!(windows.len(), 1);
9078        assert_eq!(
9079            windows[0].get("branch").and_then(Value::as_str),
9080            Some("main")
9081        );
9082        // No upstream configured → the ahead/behind keys are absent, not zero.
9083        assert!(windows[0].get("ahead").is_none());
9084        assert!(windows[0].get("behind").is_none());
9085        // The main repo name is enriched onto the entry.
9086        assert_eq!(
9087            windows[0].get("main_repo").and_then(Value::as_str),
9088            dir.path().file_name().and_then(|n| n.to_str())
9089        );
9090
9091        // A non-repo folder is still listed, just without a branch or main repo.
9092        let plain = tempfile::tempdir().unwrap();
9093        svc.handle(
9094            "register",
9095            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9096        )
9097        .await
9098        .unwrap();
9099        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9100        let w2 = windows
9101            .iter()
9102            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9103            .unwrap();
9104        assert!(w2.get("branch").is_none());
9105        assert!(w2.get("main_repo").is_none());
9106    }
9107
9108    #[test]
9109    fn window_label_prefers_git_branch_over_title() {
9110        let dir = tempfile::tempdir().unwrap();
9111        let repo = init_repo(dir.path());
9112        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9113        repo.set_head("refs/heads/main").unwrap();
9114        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9115        let entry = WindowEntry {
9116            key: "k".to_string(),
9117            folders: vec![dir.path().to_path_buf()],
9118            // Both the companion `repo` and `title` are overridden by the
9119            // git-derived main repo name and computed branch.
9120            repo: Some("companion-repo".to_string()),
9121            title: Some("ignored title".to_string()),
9122            pid: None,
9123            last_seen: Utc::now(),
9124        };
9125        // Main checkout: `repo · branch`, and with no upstream there is no sync.
9126        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9127    }
9128
9129    #[tokio::test]
9130    async fn list_includes_ahead_behind_for_tracking_branch() {
9131        let dir = tempfile::tempdir().unwrap();
9132        let _repo = diverging_repo(dir.path());
9133
9134        let svc = WorktreesService::new();
9135        svc.handle(
9136            "register",
9137            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9138        )
9139        .await
9140        .unwrap();
9141        let payload = svc.handle("list", Value::Null).await.unwrap();
9142        let windows = windows_of(&payload);
9143        // A tracking branch serializes branch plus both divergence counts.
9144        assert_eq!(
9145            windows[0].get("branch").and_then(Value::as_str),
9146            Some("main")
9147        );
9148        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9149        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9150    }
9151
9152    #[test]
9153    fn window_label_includes_sync_for_tracking_branch() {
9154        let dir = tempfile::tempdir().unwrap();
9155        let _repo = diverging_repo(dir.path());
9156        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9157        let entry = WindowEntry {
9158            key: "k".to_string(),
9159            folders: vec![dir.path().to_path_buf()],
9160            repo: Some("companion-repo".to_string()),
9161            title: None,
9162            pid: None,
9163            last_seen: Utc::now(),
9164        };
9165        // A tracking branch appends the `(+ahead -behind)` sync indicator.
9166        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9167    }
9168
9169    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
9170    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
9171    /// <wt_path>`.
9172    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9173        let commit = repo.find_commit(base).unwrap();
9174        repo.branch(branch, &commit, false).unwrap();
9175        let reference = repo
9176            .find_reference(&format!("refs/heads/{branch}"))
9177            .unwrap();
9178        let mut opts = git2::WorktreeAddOptions::new();
9179        opts.reference(Some(&reference));
9180        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9181    }
9182
9183    #[test]
9184    fn git_status_marks_linked_worktree_and_names_parent_repo() {
9185        let main_dir = tempfile::tempdir().unwrap();
9186        let repo = init_repo(main_dir.path());
9187        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9188        repo.set_head("refs/heads/main").unwrap();
9189
9190        // A linked worktree checked out on a new `feature` branch, in a
9191        // directory whose basename is deliberately *not* the repo name.
9192        let wt_parent = tempfile::tempdir().unwrap();
9193        let wt_path = wt_parent.path().join("feature-wt");
9194        add_worktree(&repo, a, &wt_path, "feature");
9195
9196        let status = git_status(&wt_path);
9197        assert!(status.is_worktree);
9198        assert_eq!(status.branch.as_deref(), Some("feature"));
9199        // The worktree names its *parent* repo, not its worktree-folder basename.
9200        assert_eq!(
9201            status.main_repo.as_deref(),
9202            main_dir.path().file_name().and_then(|n| n.to_str())
9203        );
9204
9205        // The main checkout resolves the same repo name and is not a worktree.
9206        let main_status = git_status(main_dir.path());
9207        assert!(!main_status.is_worktree);
9208        assert_eq!(main_status.main_repo, status.main_repo);
9209    }
9210
9211    #[test]
9212    fn window_label_marks_worktree_with_fork_glyph() {
9213        let main_dir = tempfile::tempdir().unwrap();
9214        let repo = init_repo(main_dir.path());
9215        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9216        repo.set_head("refs/heads/main").unwrap();
9217        let wt_parent = tempfile::tempdir().unwrap();
9218        let wt_path = wt_parent.path().join("feature-wt");
9219        add_worktree(&repo, a, &wt_path, "feature");
9220
9221        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9222        let entry = WindowEntry {
9223            key: "k".to_string(),
9224            folders: vec![wt_path],
9225            repo: Some("feature-wt".to_string()),
9226            title: None,
9227            pid: None,
9228            last_seen: Utc::now(),
9229        };
9230        // A worktree line: parent repo, the fork glyph, then the branch (no
9231        // upstream here, so no sync suffix).
9232        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9233    }
9234
9235    #[test]
9236    fn main_repo_name_derives_from_common_dir() {
9237        // Normal layout: the repo is the directory that contains `.git`.
9238        assert_eq!(
9239            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9240            Some("omni-dev")
9241        );
9242        // A trailing slash on the common dir does not change the answer.
9243        assert_eq!(
9244            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9245            Some("omni-dev")
9246        );
9247        // A bare repo: its own directory name, without the `.git` suffix.
9248        assert_eq!(
9249            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9250            Some("omni-dev")
9251        );
9252        // A `.git` at the filesystem root has no parent name to use.
9253        assert_eq!(main_repo_name(Path::new("/.git")), None);
9254    }
9255
9256    // --- Repo/worktree tree (#1265) ----------------------------------------
9257
9258    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
9259    /// temporary payload).
9260    fn repos_of(payload: &Value) -> Vec<Value> {
9261        payload
9262            .get("repos")
9263            .and_then(Value::as_array)
9264            .expect("repos array")
9265            .clone()
9266    }
9267
9268    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9269        Some(GithubIdentity {
9270            owner: owner.to_string(),
9271            name: name.to_string(),
9272        })
9273    }
9274
9275    #[test]
9276    fn github_identity_parses_supported_forms() {
9277        // https / http, with and without the `.git` suffix.
9278        assert_eq!(
9279            github_identity("https://github.com/rust-works/omni-dev.git"),
9280            github("rust-works", "omni-dev")
9281        );
9282        assert_eq!(
9283            github_identity("https://github.com/rust-works/omni-dev"),
9284            github("rust-works", "omni-dev")
9285        );
9286        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9287        // SCP-like and ssh:// / git:// forms.
9288        assert_eq!(
9289            github_identity("git@github.com:rust-works/omni-dev.git"),
9290            github("rust-works", "omni-dev")
9291        );
9292        assert_eq!(
9293            github_identity("ssh://git@github.com/o/r.git"),
9294            github("o", "r")
9295        );
9296        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9297        // A trailing slash and surrounding whitespace are tolerated.
9298        assert_eq!(
9299            github_identity("  https://github.com/o/r/  "),
9300            github("o", "r")
9301        );
9302    }
9303
9304    #[test]
9305    fn github_identity_rejects_non_github_and_malformed() {
9306        // Non-GitHub hosts.
9307        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9308        assert_eq!(github_identity("git@example.com:o/r.git"), None);
9309        // Missing or extra path segments.
9310        assert_eq!(github_identity("https://github.com/onlyowner"), None);
9311        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9312        assert_eq!(github_identity("https://github.com/"), None);
9313        // Not a URL at all.
9314        assert_eq!(github_identity("not a url"), None);
9315    }
9316
9317    #[test]
9318    fn remote_github_identity_reads_origin_then_falls_back() {
9319        let dir = tempfile::tempdir().unwrap();
9320        let repo = init_repo(dir.path());
9321        // No remotes → None.
9322        assert_eq!(remote_github_identity(&repo), None);
9323        // A non-GitHub origin is not a match.
9324        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9325        assert_eq!(remote_github_identity(&repo), None);
9326        // A GitHub origin resolves to its identity.
9327        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9328            .unwrap();
9329        assert_eq!(
9330            remote_github_identity(&repo),
9331            github("rust-works", "omni-dev")
9332        );
9333
9334        // Origin non-GitHub but another remote is GitHub: the fallback loop over
9335        // the remaining remotes finds it.
9336        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9337            .unwrap();
9338        repo.remote("upstream", "https://github.com/other/proj.git")
9339            .unwrap();
9340        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9341    }
9342
9343    #[tokio::test]
9344    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9345        let svc = WorktreesService::new();
9346        // No windows → an empty repo set (not an error), toggle at its default.
9347        assert_eq!(
9348            svc.handle("tree", Value::Null).await.unwrap(),
9349            json!({ "repos": [], "show_closed": true })
9350        );
9351        // A plain non-repo folder is skipped rather than sinking the op.
9352        let plain = tempfile::tempdir().unwrap();
9353        svc.handle(
9354            "register",
9355            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9356        )
9357        .await
9358        .unwrap();
9359        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9360    }
9361
9362    #[tokio::test]
9363    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9364        let main_dir = tempfile::tempdir().unwrap();
9365        let repo = init_repo(main_dir.path());
9366        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9367        repo.set_head("refs/heads/main").unwrap();
9368        // A GitHub origin so the repo carries an identity in the payload.
9369        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9370            .unwrap();
9371
9372        // A linked worktree on a new `feature` branch, in a directory whose
9373        // basename is deliberately not the repo name.
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        // A window open on the main checkout and one on the linked worktree —
9380        // two windows, but one repo (they must dedupe).
9381        svc.handle(
9382            "register",
9383            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9384        )
9385        .await
9386        .unwrap();
9387        svc.handle(
9388            "register",
9389            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9390        )
9391        .await
9392        .unwrap();
9393
9394        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9395        assert_eq!(
9396            repos.len(),
9397            1,
9398            "two worktrees of one repo dedupe: {repos:?}"
9399        );
9400        let repo0 = &repos[0];
9401        // Repo identity is the parent-repo name (not a worktree-folder basename).
9402        assert_eq!(
9403            repo0.get("main_repo").and_then(Value::as_str),
9404            main_dir.path().file_name().and_then(|n| n.to_str())
9405        );
9406        assert_eq!(
9407            repo0.pointer("/github/owner").and_then(Value::as_str),
9408            Some("rust-works")
9409        );
9410        assert_eq!(
9411            repo0.pointer("/github/name").and_then(Value::as_str),
9412            Some("omni-dev")
9413        );
9414        assert!(repo0.get("root").and_then(Value::as_str).is_some());
9415
9416        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9417        assert_eq!(worktrees.len(), 2);
9418        // Main working tree first: is_main, open, with the main window's key.
9419        let main_wt = &worktrees[0];
9420        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9421        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9422        assert_eq!(
9423            main_wt.get("window_key").and_then(Value::as_str),
9424            Some("wm")
9425        );
9426        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9427        // Linked worktree: not main, open via the feature window.
9428        let linked = &worktrees[1];
9429        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9430        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9431        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9432        assert_eq!(
9433            linked.get("branch").and_then(Value::as_str),
9434            Some("feature")
9435        );
9436    }
9437
9438    #[tokio::test]
9439    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9440        let main_dir = tempfile::tempdir().unwrap();
9441        let repo = init_repo(main_dir.path());
9442        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9443        repo.set_head("refs/heads/main").unwrap();
9444        // No remote at all → the repo carries no `github` identity.
9445        let wt_parent = tempfile::tempdir().unwrap();
9446        let wt_path = wt_parent.path().join("feature-wt");
9447        add_worktree(&repo, a, &wt_path, "feature");
9448
9449        let svc = WorktreesService::new();
9450        // Only the main checkout has a window; the linked worktree has none.
9451        svc.handle(
9452            "register",
9453            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9454        )
9455        .await
9456        .unwrap();
9457
9458        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9459        assert_eq!(repos.len(), 1);
9460        assert!(repos[0].get("github").is_none(), "no remote → no github");
9461        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9462        let linked = worktrees
9463            .iter()
9464            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9465            .expect("the linked worktree");
9466        // Enumerated even though no window has it open, and marked closed.
9467        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9468        assert!(linked.get("window_key").is_none());
9469    }
9470
9471    // --- Close op (#1277) --------------------------------------------------
9472
9473    /// Builds a repo whose main working tree is on `trunk` with one **clean**
9474    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
9475    /// paths stay valid) and the linked worktree path.
9476    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9477        let main_dir = tempfile::tempdir().unwrap();
9478        let repo = init_repo(main_dir.path());
9479        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9480        repo.set_head("refs/heads/trunk").unwrap();
9481        let wt_parent = tempfile::tempdir().unwrap();
9482        let wt_path = wt_parent.path().join("feature-wt");
9483        add_worktree(&repo, a, &wt_path, "feature");
9484        (main_dir, wt_parent, wt_path)
9485    }
9486
9487    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
9488    /// repo — the shape a multi-select delete fans out over, and the only one where
9489    /// two prunes share a `.git/worktrees` to race on (#1359).
9490    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9491    {
9492        let main_dir = tempfile::tempdir().unwrap();
9493        let repo = init_repo(main_dir.path());
9494        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9495        repo.set_head("refs/heads/trunk").unwrap();
9496        let wt_parent = tempfile::tempdir().unwrap();
9497        let first = wt_parent.path().join("first-wt");
9498        let second = wt_parent.path().join("second-wt");
9499        add_worktree(&repo, a, &first, "first");
9500        add_worktree(&repo, a, &second, "second");
9501        (main_dir, wt_parent, first, second)
9502    }
9503
9504    #[tokio::test]
9505    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9506        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9507        let svc = Arc::new(WorktreesService::new());
9508
9509        // The multi-select fan-out: one `close` op per target, both in flight at
9510        // once against the one repo's shared admin state. Genuinely concurrent
9511        // even on this single-threaded runtime — each op's prune is a
9512        // `spawn_blocking`, so awaiting its join yields to the other op.
9513        //
9514        // This guards the fan-out end-to-end (both ops complete, neither is
9515        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
9516        // a race detector for the lock, because it is not one — it passes with the
9517        // guard removed, the two prunes being far too quick to collide reliably.
9518        let close = |path: PathBuf| {
9519            let svc = svc.clone();
9520            async move {
9521                svc.handle(
9522                    "close",
9523                    json!({ "path": path, "remove": true, "confirmed": true }),
9524                )
9525                .await
9526            }
9527        };
9528        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9529
9530        assert_eq!(a.unwrap(), json!({ "removed": true }));
9531        assert_eq!(b.unwrap(), json!({ "removed": true }));
9532        assert!(!first.exists());
9533        assert!(!second.exists());
9534        // Both *admin* entries pruned too, not merely the directories — the half
9535        // the two ops contend on.
9536        let repo = Repository::open(main_dir.path()).unwrap();
9537        assert!(repo.worktrees().unwrap().is_empty());
9538    }
9539
9540    // --- Merge-queue op (#1401) --------------------------------------------
9541
9542    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
9543    /// upstream points at the **same** commit (nothing to push) and a github
9544    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
9545    /// empty (clean) working directory.
9546    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9547        let repo = init_repo(dir);
9548        let refname = format!("refs/heads/{branch}");
9549        let head = empty_commit(&repo, Some(&refname), &[], "A");
9550        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9551            .unwrap();
9552        repo.set_head(&refname).unwrap();
9553        let mut cfg = repo.config().unwrap();
9554        cfg.set_str("remote.origin.url", url).unwrap();
9555        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9556            .unwrap();
9557        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9558            .unwrap();
9559        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9560            .unwrap();
9561        repo
9562    }
9563
9564    #[test]
9565    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9566        let dir = tempfile::tempdir().unwrap();
9567        let _repo = pushed_github_repo(
9568            dir.path(),
9569            "https://github.com/rust-works/omni-dev.git",
9570            "feature",
9571        );
9572        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9573        assert_eq!(
9574            ok.target,
9575            PrTarget {
9576                owner: "rust-works".into(),
9577                name: "omni-dev".into(),
9578                branch: "feature".into(),
9579            }
9580        );
9581        assert!(!ok.head_sha.is_empty());
9582    }
9583
9584    #[test]
9585    fn evaluate_local_skips_an_unborn_head() {
9586        let dir = tempfile::tempdir().unwrap();
9587        let _repo = init_repo(dir.path()); // no commits
9588        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9589    }
9590
9591    #[test]
9592    fn evaluate_local_skips_a_branch_with_no_upstream() {
9593        let dir = tempfile::tempdir().unwrap();
9594        let repo = init_repo(dir.path());
9595        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9596        repo.set_head("refs/heads/main").unwrap();
9597        // A github URL but no tracking config → nothing was ever pushed.
9598        repo.config()
9599            .unwrap()
9600            .set_str("remote.origin.url", "https://github.com/o/r.git")
9601            .unwrap();
9602        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9603    }
9604
9605    #[test]
9606    fn evaluate_local_skips_unpushed_local_commits() {
9607        let dir = tempfile::tempdir().unwrap();
9608        let repo = init_repo(dir.path());
9609        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9610        let a_commit = repo.find_commit(a).unwrap();
9611        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
9612        repo.reference("refs/remotes/origin/main", a, true, "o")
9613            .unwrap();
9614        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9615        drop(a_commit);
9616        repo.set_head("refs/heads/main").unwrap();
9617        let mut cfg = repo.config().unwrap();
9618        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9619            .unwrap();
9620        // The fetch refspec is what lets git2 map the branch to its tracking ref;
9621        // without it `upstream()` fails and the branch reads as having none.
9622        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9623            .unwrap();
9624        cfg.set_str("branch.main.remote", "origin").unwrap();
9625        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9626        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9627    }
9628
9629    #[test]
9630    fn evaluate_local_skips_a_detached_head() {
9631        let dir = tempfile::tempdir().unwrap();
9632        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9633        let head = repo.head().unwrap().target().unwrap();
9634        repo.set_head_detached(head).unwrap();
9635        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9636    }
9637
9638    #[test]
9639    fn evaluate_local_skips_a_non_github_remote() {
9640        let dir = tempfile::tempdir().unwrap();
9641        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9642        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9643    }
9644
9645    #[test]
9646    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9647        // A path git cannot discover a repo from is refused up front (defensive:
9648        // the UI only ever sends real worktrees). A nonexistent path is used so the
9649        // result never depends on whether the temp dir sits inside a checkout.
9650        assert_eq!(
9651            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9652                .unwrap_err()
9653                .kind,
9654            "not-a-repo"
9655        );
9656    }
9657
9658    #[test]
9659    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9660        // The audit line's `tracing` field expressions only evaluate when an INFO
9661        // subscriber is active — the sync helper makes that testable.
9662        let req = MergeQueueRequest {
9663            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9664            requester_key: Some("win-9".into()),
9665            check: true,
9666            confirmed: false,
9667        };
9668        let logs = capture_info(|| log_merge_check(&req, 1, 1));
9669        assert!(logs.contains("merge-queue check"), "{logs}");
9670        assert!(logs.contains("win-9"), "{logs}");
9671        assert!(logs.contains("requested=2"), "{logs}");
9672        assert!(logs.contains("eligible=1"), "{logs}");
9673    }
9674
9675    #[test]
9676    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9677        // A CLI-style requester (no window key) logs the dash fallback.
9678        let req = MergeQueueRequest {
9679            paths: vec![PathBuf::from("/a")],
9680            requester_key: None,
9681            check: false,
9682            confirmed: true,
9683        };
9684        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9685        assert!(logs.contains("merge-queue enqueue"), "{logs}");
9686        assert!(logs.contains("queued=2"), "{logs}");
9687        assert!(logs.contains("failed=1"), "{logs}");
9688    }
9689
9690    #[test]
9691    fn evaluate_local_flags_dirty_then_untracked() {
9692        // A linked worktree with a real checked-out file, so status is meaningful.
9693        let main_dir = tempfile::tempdir().unwrap();
9694        let repo = init_repo(main_dir.path());
9695        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9696        repo.set_head("refs/heads/main").unwrap();
9697        let wt_parent = tempfile::tempdir().unwrap();
9698        let wt_path = wt_parent.path().join("feature-wt");
9699        add_worktree(&repo, a, &wt_path, "feature");
9700
9701        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
9702        let clean = evaluate_local(&wt_path).unwrap_err();
9703        assert_ne!(clean.kind, "dirty");
9704        assert_ne!(clean.kind, "untracked");
9705
9706        // Modify the tracked file → dirty (gate 1, before any network call).
9707        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9708        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9709
9710        // Restore it, add a new file → untracked.
9711        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9712        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9713        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9714    }
9715
9716    #[test]
9717    fn is_conflicting_blocks_only_dirty_and_conflicting() {
9718        assert!(is_conflicting(Some("CONFLICTING")));
9719        assert!(is_conflicting(Some("DIRTY")));
9720        assert!(!is_conflicting(Some("CLEAN")));
9721        assert!(!is_conflicting(Some("BLOCKED")));
9722        assert!(!is_conflicting(Some("UNKNOWN")));
9723        assert!(!is_conflicting(None));
9724    }
9725
9726    #[test]
9727    fn merge_queue_request_parses_batch_and_phase_flags() {
9728        let req: MergeQueueRequest = serde_json::from_value(json!({
9729            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9730        }))
9731        .unwrap();
9732        assert_eq!(req.paths.len(), 2);
9733        assert_eq!(req.requester_key.as_deref(), Some("w1"));
9734        assert!(req.confirmed);
9735        assert!(!req.check);
9736        // Minimal payload: just paths; every other field defaults.
9737        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9738        assert!(req.paths.is_empty());
9739        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9740    }
9741
9742    #[test]
9743    fn queued_pr_omits_already_queued_when_false() {
9744        let v = serde_json::to_value(QueuedPr {
9745            path: "/a".into(),
9746            number: 5,
9747            already_queued: false,
9748        })
9749        .unwrap();
9750        assert!(v.get("already_queued").is_none(), "{v}");
9751        let v = serde_json::to_value(QueuedPr {
9752            path: "/a".into(),
9753            number: 5,
9754            already_queued: true,
9755        })
9756        .unwrap();
9757        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9758    }
9759
9760    #[tokio::test]
9761    async fn merge_queue_check_on_empty_selection_reports_nothing() {
9762        let svc = WorktreesService::new();
9763        let reply = svc
9764            .handle("merge-queue", json!({ "paths": [], "check": true }))
9765            .await
9766            .unwrap();
9767        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9768    }
9769
9770    #[tokio::test]
9771    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9772        // An unborn repo is skipped by the *local* gates, so the op never shells
9773        // `gh` — the check completes with no network stub.
9774        let dir = tempfile::tempdir().unwrap();
9775        let _repo = init_repo(dir.path());
9776        let svc = WorktreesService::new();
9777        let reply = svc
9778            .handle(
9779                "merge-queue",
9780                json!({ "paths": [dir.path()], "check": true }),
9781            )
9782            .await
9783            .unwrap();
9784        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9785        assert_eq!(skipped.len(), 1);
9786        assert_eq!(
9787            skipped[0].get("kind").and_then(Value::as_str),
9788            Some("no-commits")
9789        );
9790        assert!(reply
9791            .get("eligible")
9792            .and_then(Value::as_array)
9793            .unwrap()
9794            .is_empty());
9795    }
9796
9797    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
9798    /// that clears the local gates, so a test can drive the *network* gates by
9799    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
9800    fn ready_worktree() -> (tempfile::TempDir, String) {
9801        let dir = tempfile::tempdir().unwrap();
9802        let repo = pushed_github_repo(
9803            dir.path(),
9804            "https://github.com/rust-works/omni-dev.git",
9805            "feature",
9806        );
9807        let head = repo.head().unwrap().target().unwrap().to_string();
9808        (dir, head)
9809    }
9810
9811    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
9812    /// one check of `conclusion`, and one PR node `pr`.
9813    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9814        format!(
9815            r#"{{"data":{{"r0":{{"b0":{{
9816                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9817                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9818                ]}}}}}},
9819                "associatedPullRequests":{{"nodes":[{pr}]}}
9820            }}}}}}}}"#
9821        )
9822    }
9823
9824    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
9825    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
9826    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
9827    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9828        let ghdir = tempfile::tempdir().unwrap();
9829        let (bin, _shim) = fake_gh(ghdir.path(), reply);
9830        let paths = vec![head_dir.to_path_buf()];
9831        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9832        if let Some(e) = eligible.first() {
9833            return Ok(e.number);
9834        }
9835        Err(skipped.remove(0).kind)
9836    }
9837
9838    #[test]
9839    fn evaluate_batch_marks_a_ready_pr_eligible() {
9840        let (dir, head) = ready_worktree();
9841        let pr = format!(
9842            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9843        );
9844        assert_eq!(
9845            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9846            Ok(9)
9847        );
9848    }
9849
9850    #[test]
9851    fn evaluate_batch_skips_a_draft_pr() {
9852        let (dir, head) = ready_worktree();
9853        let pr = format!(
9854            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9855        );
9856        assert_eq!(
9857            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9858            Err("draft".to_string())
9859        );
9860    }
9861
9862    #[test]
9863    fn evaluate_batch_skips_a_conflicting_pr() {
9864        let (dir, head) = ready_worktree();
9865        let pr = format!(
9866            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9867        );
9868        assert_eq!(
9869            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9870            Err("conflicting".to_string())
9871        );
9872    }
9873
9874    #[test]
9875    fn evaluate_batch_skips_a_pr_with_failing_checks() {
9876        let (dir, head) = ready_worktree();
9877        let pr = format!(
9878            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9879        );
9880        assert_eq!(
9881            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9882            Err("checks-failing".to_string())
9883        );
9884    }
9885
9886    #[test]
9887    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9888        let (dir, head) = ready_worktree();
9889        // The remote PR head is a different commit than the local head.
9890        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9891        assert_eq!(
9892            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9893            Err("stale".to_string())
9894        );
9895    }
9896
9897    #[test]
9898    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9899        let (dir, head) = ready_worktree();
9900        // The ref resolves but no open PR heads it.
9901        let reply = format!(
9902            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9903        );
9904        assert_eq!(
9905            network_gate_outcome(dir.path(), &reply),
9906            Err("no-pr".to_string())
9907        );
9908    }
9909
9910    #[test]
9911    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9912        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
9913        // already-queued PR needs no mutation and is reported queued.
9914        let eligible = vec![
9915            Eligible {
9916                path: PathBuf::from("/wt/a"),
9917                number: 1,
9918                url: "u".into(),
9919                branch: "a".into(),
9920                pr_id: "PR_A".into(),
9921                already_queued: true,
9922            },
9923            Eligible {
9924                path: PathBuf::from("/wt/b"),
9925                number: 2,
9926                url: "u".into(),
9927                branch: "b".into(),
9928                pr_id: "PR_B".into(),
9929                already_queued: false,
9930            },
9931        ];
9932        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9933        assert_eq!(queued.len(), 1);
9934        assert_eq!(queued[0].number, 1);
9935        assert!(queued[0].already_queued);
9936        assert_eq!(failed.len(), 1);
9937        assert_eq!(failed[0].number, 2);
9938    }
9939
9940    #[test]
9941    fn enqueue_eligible_records_a_github_rejection_as_failed() {
9942        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
9943        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
9944        // rather than sinking the batch.
9945        let ghdir = tempfile::tempdir().unwrap();
9946        let (bin, _shim) = fake_gh(
9947            ghdir.path(),
9948            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9949        );
9950        let eligible = vec![Eligible {
9951            path: PathBuf::from("/wt/a"),
9952            number: 7,
9953            url: "u".into(),
9954            branch: "a".into(),
9955            pr_id: "PR_A".into(),
9956            already_queued: false,
9957        }];
9958        let (queued, failed) = enqueue_eligible(&bin, eligible);
9959        assert!(queued.is_empty(), "{queued:?}");
9960        assert_eq!(failed.len(), 1);
9961        assert_eq!(failed[0].number, 7);
9962        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
9963        // instead surface as an `Err`, still landing in `failed[]`).
9964        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9965    }
9966
9967    #[tokio::test]
9968    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9969    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9970        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
9971        // exercising the `PrRef` mapping the empty-selection tests cannot.
9972        let (dir, head) = ready_worktree();
9973        let pr = format!(
9974            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9975        );
9976        let ghdir = tempfile::tempdir().unwrap();
9977        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9978        let svc = WorktreesService::new();
9979        let req = MergeQueueRequest {
9980            paths: vec![dir.path().to_path_buf()],
9981            requester_key: None,
9982            check: true,
9983            confirmed: false,
9984        };
9985        // `merge_queue_with` shells the freshly-written `fake-gh` shim via
9986        // `spawn_blocking` — a test-only ETXTBSY exec race the `shim_lock`
9987        // guard alone does not retry (see test_support::shim's module docs).
9988        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9989            .await
9990            .unwrap();
9991        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9992        assert_eq!(eligible.len(), 1);
9993        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9994        assert_eq!(
9995            eligible[0].get("branch").and_then(Value::as_str),
9996            Some("feature")
9997        );
9998    }
9999
10000    #[tokio::test]
10001    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
10002    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
10003        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
10004        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
10005        let (dir, head) = ready_worktree();
10006        let pr = format!(
10007            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10008        );
10009        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
10010        let ghdir = tempfile::tempdir().unwrap();
10011        let guard = shim_lock();
10012        let bin = ghdir.path().join("fake-gh");
10013        write_exec_script(
10014            &bin,
10015            &format!(
10016                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
10017                enqueue =
10018                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
10019            ),
10020        );
10021        let svc = WorktreesService::new();
10022        let req = MergeQueueRequest {
10023            paths: vec![dir.path().to_path_buf()],
10024            requester_key: Some("w1".into()),
10025            check: false,
10026            confirmed: true,
10027        };
10028        // `merge_queue_with` shells the freshly-written `fake-gh` shim (twice,
10029        // for the resolve then the enqueue) via `spawn_blocking` — a test-only
10030        // ETXTBSY exec race the `shim_lock` guard alone does not retry (see
10031        // test_support::shim's module docs).
10032        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10033            .await
10034            .unwrap();
10035        drop(guard);
10036        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
10037        assert_eq!(queued.len(), 1, "{reply}");
10038        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
10039        assert!(reply
10040            .get("failed")
10041            .and_then(Value::as_array)
10042            .unwrap()
10043            .is_empty());
10044    }
10045
10046    #[tokio::test]
10047    async fn concurrent_closes_overlap_their_heartbeat_waits() {
10048        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
10049        let svc = Arc::new(WorktreesService::new());
10050        // Two *different* windows own the two targets — the multi-select shape.
10051        for (key, path) in [("w2", &first), ("w3", &second)] {
10052            svc.handle("register", json!({ "key": key, "folders": [path] }))
10053                .await
10054                .unwrap();
10055        }
10056
10057        let spawn_close = |path: PathBuf| {
10058            let svc = svc.clone();
10059            tokio::spawn(async move {
10060                svc.handle(
10061                    "close",
10062                    json!({
10063                        "path": path,
10064                        "remove": true,
10065                        "confirmed": true,
10066                        "requester_key": "w1",
10067                    }),
10068                )
10069                .await
10070            })
10071        };
10072        let a = spawn_close(first.clone());
10073        let b = spawn_close(second.clone());
10074
10075        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
10076        // *both* windows are told to close while *neither* op has finished, so the
10077        // two multi-second heartbeat waits are in flight at once. Take the guard
10078        // before `await_windows_closed` instead of after and this fails — op B
10079        // would sit on the lock without ever marking w3, restoring exactly the
10080        // N-stacked-waits latency the fan-out exists to remove.
10081        for key in ["w2", "w3"] {
10082            let mut saw_close = false;
10083            for _ in 0..400 {
10084                let hb = svc
10085                    .handle("heartbeat", json!({ "key": key }))
10086                    .await
10087                    .unwrap();
10088                if hb.get("close").and_then(Value::as_bool) == Some(true) {
10089                    saw_close = true;
10090                    break;
10091                }
10092                tokio::time::sleep(Duration::from_millis(5)).await;
10093            }
10094            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10095        }
10096        assert!(
10097            !a.is_finished() && !b.is_finished(),
10098            "neither close can have finished: both windows are still registered"
10099        );
10100
10101        // Both windows close; both ops then remove.
10102        for key in ["w2", "w3"] {
10103            svc.handle("unregister", json!({ "key": key }))
10104                .await
10105                .unwrap();
10106        }
10107        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10108        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10109        assert!(!first.exists());
10110        assert!(!second.exists());
10111    }
10112
10113    #[tokio::test]
10114    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10115        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10116        let svc = WorktreesService::new();
10117        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
10118        // main, no risks → the extension proceeds with no dialog.
10119        let report = svc
10120            .handle("close", json!({ "path": wt_path, "remove": true }))
10121            .await
10122            .unwrap();
10123        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10124        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10125        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10126        assert!(report
10127            .get("risks")
10128            .and_then(Value::as_array)
10129            .unwrap()
10130            .is_empty());
10131        // No side effects: the worktree still exists.
10132        assert!(wt_path.exists());
10133    }
10134
10135    #[tokio::test]
10136    async fn close_removes_a_clean_linked_worktree() {
10137        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10138        let svc = WorktreesService::new();
10139        let reply = svc
10140            .handle(
10141                "close",
10142                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10143            )
10144            .await
10145            .unwrap();
10146        assert_eq!(reply, json!({ "removed": true }));
10147        assert!(
10148            !wt_path.exists(),
10149            "the worktree directory should be deleted"
10150        );
10151    }
10152
10153    // --- Close-op audit logging (#1364) ------------------------------------
10154
10155    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
10156    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
10157    /// `with_default`, so it never disturbs a global subscriber other tests set.
10158    /// The audit-line tests drive the sync helpers directly (no runtime, no
10159    /// `spawn_blocking`), so the captured events fire on this thread where the
10160    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
10161    /// under the parallel suite is *not* reliably captured this way.
10162    ///
10163    /// Thin alias over [`crate::test_support::capture_at`] at `INFO`.
10164    fn capture_info(f: impl FnOnce()) -> String {
10165        crate::test_support::capture_at(tracing::Level::INFO, f)
10166    }
10167
10168    // ── rebase op (#1415) ──────────────────────────────────────────────────
10169
10170    /// A `RebaseRequest` over `paths` with everything else defaulted.
10171    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10172        RebaseRequest {
10173            paths,
10174            requester_key: None,
10175            check: false,
10176            confirmed: false,
10177            keep_conflicts: false,
10178            autostash: false,
10179            onto: None,
10180        }
10181    }
10182
10183    /// A repo whose `main` has advanced one commit past a linked `feature`
10184    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
10185    ///
10186    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
10187    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
10188    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
10189    /// own concern and is covered in `worktree_rebase.rs`.
10190    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10191        let main_dir = tempfile::tempdir().unwrap();
10192        let repo = init_repo(main_dir.path());
10193        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10194        repo.set_head("refs/heads/main").unwrap();
10195        let wt_parent = tempfile::tempdir().unwrap();
10196        let wt_path = wt_parent.path().join("feature-wt");
10197        add_worktree(&repo, base, &wt_path, "feature");
10198        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
10199        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10200        (main_dir, wt_parent, wt_path)
10201    }
10202
10203    #[tokio::test]
10204    async fn rebase_with_refuses_an_empty_selection() {
10205        // A bare `rebase` must be a usage error, never a silent mass-rebase.
10206        let svc = WorktreesService::new();
10207        let err = svc
10208            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10209            .await
10210            .unwrap_err()
10211            .to_string();
10212        assert!(err.contains("at least one path"), "{err}");
10213    }
10214
10215    #[tokio::test]
10216    async fn rebase_with_phase_one_reports_without_rebasing() {
10217        let (_main, _parent, wt) = behind_worktree();
10218        let before = Repository::open(&wt).unwrap().head().unwrap().target();
10219
10220        let svc = WorktreesService::new();
10221        let reply = svc
10222            .rebase_with(
10223                RebaseRequest {
10224                    check: true,
10225                    onto: Some("main".into()),
10226                    ..rebase_req(vec![wt.clone()])
10227                },
10228                crate::git::resolve_git_binary(),
10229            )
10230            .await
10231            .unwrap();
10232
10233        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10234        assert_eq!(worktrees.len(), 1, "{reply}");
10235        assert_eq!(
10236            worktrees[0].get("status").and_then(Value::as_str),
10237            Some("would-rebase"),
10238            "{reply}"
10239        );
10240        // A local onto ref means no fetch was attempted at all.
10241        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10242        assert_eq!(fetches.len(), 1);
10243        assert_eq!(
10244            fetches[0].get("fetched").and_then(Value::as_bool),
10245            Some(false)
10246        );
10247        assert_eq!(
10248            Repository::open(&wt).unwrap().head().unwrap().target(),
10249            before,
10250            "phase 1 must not move the branch"
10251        );
10252    }
10253
10254    #[tokio::test]
10255    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10256        let (_main, _parent, wt) = behind_worktree();
10257        let svc = WorktreesService::new();
10258        let reply = svc
10259            .rebase_with(
10260                RebaseRequest {
10261                    confirmed: true,
10262                    onto: Some("main".into()),
10263                    ..rebase_req(vec![wt.clone()])
10264                },
10265                crate::git::resolve_git_binary(),
10266            )
10267            .await
10268            .unwrap();
10269
10270        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10271        assert_eq!(
10272            worktrees[0].get("status").and_then(Value::as_str),
10273            Some("rebased"),
10274            "{reply}"
10275        );
10276        // The transient cue is cleared on the way out, so no row keeps spinning.
10277        assert!(
10278            svc.registry.rebasing_paths().is_empty(),
10279            "the rebasing mark must be cleared after the execute"
10280        );
10281    }
10282
10283    #[tokio::test]
10284    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10285        // The re-validation that makes two-phase meaningful: a `confirmed` request
10286        // still runs the classifier, so a worktree that is dirty *now* is skipped
10287        // rather than rebased on the strength of an earlier phase-1 verdict.
10288        let (_main, _parent, wt) = behind_worktree();
10289        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10290
10291        let svc = WorktreesService::new();
10292        let reply = svc
10293            .rebase_with(
10294                RebaseRequest {
10295                    confirmed: true,
10296                    onto: Some("main".into()),
10297                    ..rebase_req(vec![wt.clone()])
10298                },
10299                crate::git::resolve_git_binary(),
10300            )
10301            .await
10302            .unwrap();
10303
10304        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10305        assert_eq!(
10306            worktrees[0].get("status").and_then(Value::as_str),
10307            Some("skipped"),
10308            "{reply}"
10309        );
10310        assert_eq!(
10311            worktrees[0].get("reason").and_then(Value::as_str),
10312            Some("dirty"),
10313            "{reply}"
10314        );
10315    }
10316
10317    #[tokio::test]
10318    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10319        // The hazard the plan-under-the-lock ordering exists to prevent: if another
10320        // run has left a worktree mid-rebase, this one must classify it as
10321        // `operation-in-progress` and leave it alone — never run `git rebase`
10322        // against it, which (without `keep_conflicts`) would `--abort` and destroy
10323        // the conflict resolution in progress.
10324        let (_main, _parent, wt) = behind_worktree();
10325        // Fake a rebase in progress the way git does: the state directory's
10326        // presence is what `Repository::state()` keys on.
10327        std::fs::create_dir_all(wt.join(".git")).ok();
10328        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10329        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10330        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10331        assert_ne!(
10332            Repository::open(&wt).unwrap().state(),
10333            RepositoryState::Clean,
10334            "precondition: the worktree looks mid-rebase to git2"
10335        );
10336
10337        let svc = WorktreesService::new();
10338        let reply = svc
10339            .rebase_with(
10340                RebaseRequest {
10341                    confirmed: true,
10342                    onto: Some("main".into()),
10343                    ..rebase_req(vec![wt.clone()])
10344                },
10345                crate::git::resolve_git_binary(),
10346            )
10347            .await
10348            .unwrap();
10349
10350        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10351        assert_eq!(
10352            worktrees[0].get("reason").and_then(Value::as_str),
10353            Some("operation-in-progress"),
10354            "{reply}"
10355        );
10356        // Still mid-rebase: nothing aborted it out from under whoever owns it.
10357        assert_ne!(
10358            Repository::open(&wt).unwrap().state(),
10359            RepositoryState::Clean
10360        );
10361    }
10362
10363    #[test]
10364    fn rebase_request_maps_onto_engine_options() {
10365        let req = RebaseRequest {
10366            keep_conflicts: true,
10367            autostash: true,
10368            onto: Some("origin/release".into()),
10369            ..rebase_req(vec![PathBuf::from("/wt")])
10370        };
10371        let opts = req.options(PathBuf::from("/custom/git"));
10372        assert!(opts.keep_conflicts && opts.autostash);
10373        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10374        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10375        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
10376        // engine's own flag stays off — setting it would be a second, redundant
10377        // gate that could silently no-op a confirmed execute.
10378        assert!(!opts.dry_run);
10379    }
10380
10381    #[test]
10382    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10383        let req = RebaseRequest {
10384            requester_key: Some("win-3".into()),
10385            check: true,
10386            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10387        };
10388        let plan = worktree_rebase::Plan {
10389            fetches: vec![worktree_rebase::FetchOutcome {
10390                repo_root: PathBuf::from("/repo"),
10391                onto: "origin/main".into(),
10392                fetched: true,
10393                ok: false,
10394                detail: Some("host unreachable".into()),
10395            }],
10396            worktrees: vec![
10397                worktree_rebase::WorktreeOutcome {
10398                    path: PathBuf::from("/a"),
10399                    branch: Some("a".into()),
10400                    onto: "origin/main".into(),
10401                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10402                },
10403                worktree_rebase::WorktreeOutcome {
10404                    path: PathBuf::from("/b"),
10405                    branch: Some("b".into()),
10406                    onto: "origin/main".into(),
10407                    result: worktree_rebase::RebaseResult::UpToDate,
10408                },
10409            ],
10410        };
10411        let logs = capture_info(|| log_rebase_check(&req, &plan));
10412        assert!(logs.contains("rebase check"), "{logs}");
10413        assert!(logs.contains("win-3"), "{logs}");
10414        assert!(logs.contains("requested=2"), "{logs}");
10415        assert!(logs.contains("pending=1"), "{logs}");
10416        assert!(logs.contains("failed_fetches=1"), "{logs}");
10417    }
10418
10419    #[test]
10420    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10421        // A CLI-style requester (no window key) logs the dash fallback.
10422        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10423        let outcome = |result| worktree_rebase::WorktreeOutcome {
10424            path: PathBuf::from("/x"),
10425            branch: Some("x".into()),
10426            onto: "origin/main".into(),
10427            result,
10428        };
10429        let outcomes = vec![
10430            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10431            outcome(worktree_rebase::RebaseResult::Conflict {
10432                detail: "CONFLICT".into(),
10433                left_in_place: true,
10434            }),
10435            outcome(worktree_rebase::RebaseResult::Skipped {
10436                reason: worktree_rebase::SkipReason::Dirty,
10437            }),
10438        ];
10439        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10440        assert!(logs.contains("rebase execute"), "{logs}");
10441        assert!(logs.contains("rebased=1"), "{logs}");
10442        assert!(logs.contains("conflicts=1"), "{logs}");
10443        assert!(logs.contains("left_in_place=1"), "{logs}");
10444        assert!(logs.contains("skipped=1"), "{logs}");
10445        assert!(logs.contains(r#"requester="-""#), "{logs}");
10446    }
10447
10448    // --- Push op (#1443) ---------------------------------------------------
10449
10450    /// A `PushRequest` with every field defaulted, for terse test construction.
10451    fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10452        PushRequest {
10453            paths,
10454            requester_key: None,
10455            check: false,
10456            confirmed: false,
10457        }
10458    }
10459
10460    /// A bare `origin`, a local clone of `main`, and a linked `feature` worktree
10461    /// whose branch is published and then **rewritten** — i.e. exactly what a
10462    /// rebase leaves behind, the state `push` exists for.
10463    ///
10464    /// Shells out to `git` (so the push has a real remote to talk to) under the
10465    /// shared serialization guard the other git-heavy tests use.
10466    fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10467        // Held for the fixture only — that dozen-subprocess burst is what the lock
10468        // exists to cap — and released on return, before any `.await` in the test.
10469        let _guard = crate::git::worktree_batch::test_serial_lock();
10470        let root = tempfile::tempdir().unwrap();
10471        let origin = root.path().join("origin.git");
10472        let local = root.path().join("local");
10473        let wt = root.path().join("feature-wt");
10474        std::fs::create_dir_all(&origin).unwrap();
10475        std::fs::create_dir_all(&local).unwrap();
10476
10477        let git = |dir: &Path, args: &[&str]| {
10478            let out = crate::git::worktree_batch::run_git_in(
10479                &crate::git::resolve_git_binary(),
10480                dir,
10481                args,
10482            )
10483            .unwrap();
10484            assert!(
10485                out.status.success(),
10486                "git {args:?} failed: {}",
10487                String::from_utf8_lossy(&out.stderr)
10488            );
10489        };
10490
10491        git(&origin, &["init", "--bare", "-b", "main"]);
10492        git(&local, &["init", "-b", "main"]);
10493        git(&local, &["config", "user.name", "Test"]);
10494        git(&local, &["config", "user.email", "test@example.com"]);
10495        git(&local, &["config", "commit.gpgsign", "false"]);
10496        std::fs::write(local.join("f.txt"), "base\n").unwrap();
10497        git(&local, &["add", "f.txt"]);
10498        git(&local, &["commit", "-m", "base"]);
10499        git(
10500            &local,
10501            &["remote", "add", "origin", origin.to_str().unwrap()],
10502        );
10503        git(&local, &["push", "-u", "origin", "main"]);
10504        git(
10505            &local,
10506            &[
10507                "worktree",
10508                "add",
10509                "-b",
10510                "feature",
10511                wt.to_str().unwrap(),
10512                "main",
10513            ],
10514        );
10515        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10516        git(&wt, &["add", "g.txt"]);
10517        git(&wt, &["commit", "-m", "work"]);
10518        git(&wt, &["push", "-u", "origin", "feature"]);
10519        // The rewrite: `feature` now diverges from `origin/feature`.
10520        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10521
10522        (root, origin, std::fs::canonicalize(&wt).unwrap())
10523    }
10524
10525    /// The tip of `refname` in the bare origin, when it exists.
10526    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10527        Repository::open_bare(origin)
10528            .unwrap()
10529            .refname_to_id(refname)
10530            .ok()
10531    }
10532
10533    #[tokio::test]
10534    async fn push_with_refuses_an_empty_selection() {
10535        // A bare `push` must be a usage error, never a silent mass-push.
10536        let svc = WorktreesService::new();
10537        let err = svc
10538            .push_with(push_req(Vec::new()), PathBuf::from("git"))
10539            .await
10540            .unwrap_err()
10541            .to_string();
10542        assert!(err.contains("at least one path"), "{err}");
10543    }
10544
10545    #[tokio::test]
10546    async fn push_with_phase_one_reports_without_publishing() {
10547        let (_root, origin, wt) = rewritten_worktree();
10548        let before = origin_tip(&origin, "refs/heads/feature");
10549
10550        let svc = WorktreesService::new();
10551        let reply = svc
10552            .push_with(
10553                PushRequest {
10554                    check: true,
10555                    ..push_req(vec![wt.clone()])
10556                },
10557                crate::git::resolve_git_binary(),
10558            )
10559            .await
10560            .unwrap();
10561
10562        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10563        assert_eq!(worktrees.len(), 1, "{reply}");
10564        assert_eq!(
10565            worktrees[0].get("status").and_then(Value::as_str),
10566            Some("would-force"),
10567            "{reply}"
10568        );
10569        assert!(
10570            reply.get("fetches").is_none(),
10571            "a push plan contacts no remote, so it reports no fetches: {reply}"
10572        );
10573        assert_eq!(
10574            origin_tip(&origin, "refs/heads/feature"),
10575            before,
10576            "phase 1 must publish nothing"
10577        );
10578        assert!(
10579            svc.registry.pushing_paths().is_empty(),
10580            "phase 1 must not mark a row as in flight"
10581        );
10582    }
10583
10584    #[tokio::test]
10585    async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10586        let (_root, origin, wt) = rewritten_worktree();
10587        let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10588
10589        let svc = WorktreesService::new();
10590        let reply = svc
10591            .push_with(
10592                PushRequest {
10593                    confirmed: true,
10594                    ..push_req(vec![wt.clone()])
10595                },
10596                crate::git::resolve_git_binary(),
10597            )
10598            .await
10599            .unwrap();
10600
10601        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10602        assert_eq!(
10603            worktrees[0].get("status").and_then(Value::as_str),
10604            Some("pushed"),
10605            "{reply}"
10606        );
10607        assert_eq!(
10608            worktrees[0].get("forced").and_then(Value::as_bool),
10609            Some(true),
10610            "a rewritten branch is published under the lease: {reply}"
10611        );
10612        assert_eq!(
10613            origin_tip(&origin, "refs/heads/feature"),
10614            rewritten,
10615            "the remote must carry the rewritten tip"
10616        );
10617        assert!(
10618            svc.registry.pushing_paths().is_empty(),
10619            "the pushing mark must be cleared after the execute — a push writes no \
10620             on-disk state, so nothing else could ever correct a leftover"
10621        );
10622    }
10623
10624    #[tokio::test]
10625    async fn push_resolves_the_git_binary_for_itself() {
10626        // The public entry point, which `push_with` exists to let the other tests
10627        // bypass. Safe to call for real: a `check` never reaches a subprocess at
10628        // all, because planning a push contacts no remote.
10629        let (_root, origin, wt) = rewritten_worktree();
10630        let before = origin_tip(&origin, "refs/heads/feature");
10631
10632        let svc = WorktreesService::new();
10633        let reply = svc
10634            .push(PushRequest {
10635                check: true,
10636                ..push_req(vec![wt])
10637            })
10638            .await
10639            .unwrap();
10640
10641        assert_eq!(
10642            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10643                .get("status")
10644                .and_then(Value::as_str),
10645            Some("would-force"),
10646            "{reply}"
10647        );
10648        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10649    }
10650
10651    #[tokio::test]
10652    async fn push_with_defaults_to_report_only_without_confirmation() {
10653        // Neither `check` nor `confirmed`: the safe reading is "report", matching
10654        // `rebase`. A client that forgets the flag must never publish.
10655        let (_root, origin, wt) = rewritten_worktree();
10656        let before = origin_tip(&origin, "refs/heads/feature");
10657
10658        let svc = WorktreesService::new();
10659        let reply = svc
10660            .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10661            .await
10662            .unwrap();
10663
10664        assert_eq!(
10665            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10666                .get("status")
10667                .and_then(Value::as_str),
10668            Some("would-force"),
10669            "{reply}"
10670        );
10671        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10672    }
10673
10674    #[tokio::test]
10675    async fn push_with_refuses_to_force_the_remote_default_branch() {
10676        // The gate that inverts ADR-0060, enforced in the daemon rather than only
10677        // in the UI: a rewritten `main` is reported, never published.
10678        let (root, origin, _wt) = rewritten_worktree();
10679        let local = root.path().join("local");
10680        let before = origin_tip(&origin, "refs/heads/main");
10681        crate::git::worktree_batch::run_git_in(
10682            &crate::git::resolve_git_binary(),
10683            &local,
10684            &["commit", "--amend", "-m", "rewritten main"],
10685        )
10686        .unwrap();
10687
10688        let svc = WorktreesService::new();
10689        let reply = svc
10690            .push_with(
10691                PushRequest {
10692                    confirmed: true,
10693                    ..push_req(vec![local.clone()])
10694                },
10695                crate::git::resolve_git_binary(),
10696            )
10697            .await
10698            .unwrap();
10699
10700        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10701        assert_eq!(
10702            worktrees[0].get("reason").and_then(Value::as_str),
10703            Some("default-branch-force-push"),
10704            "{reply}"
10705        );
10706        assert_eq!(
10707            origin_tip(&origin, "refs/heads/main"),
10708            before,
10709            "the default branch's published history must be untouched"
10710        );
10711    }
10712
10713    #[test]
10714    fn log_push_check_separates_the_force_count_from_the_pending_count() {
10715        let req = PushRequest {
10716            requester_key: Some("win-7".into()),
10717            check: true,
10718            ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10719        };
10720        let outcome = |result| worktree_push::WorktreeOutcome {
10721            path: PathBuf::from("/x"),
10722            branch: Some("x".into()),
10723            remote: "origin".into(),
10724            remote_branch: "x".into(),
10725            result,
10726        };
10727        let plan = worktree_push::Plan {
10728            worktrees: vec![
10729                outcome(worktree_push::PushResult::WouldForce {
10730                    ahead: 1,
10731                    behind: 1,
10732                }),
10733                outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10734                outcome(worktree_push::PushResult::Skipped {
10735                    reason: worktree_push::SkipReason::DefaultBranchForcePush,
10736                }),
10737            ],
10738        };
10739        let logs = capture_info(|| log_push_check(&req, &plan));
10740        assert!(logs.contains("push check"), "{logs}");
10741        assert!(logs.contains("pending=2"), "{logs}");
10742        assert!(logs.contains("forced=1"), "{logs}");
10743        assert!(logs.contains("skipped=1"), "{logs}");
10744        assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10745    }
10746
10747    #[test]
10748    fn log_push_execute_counts_lease_refusals_separately() {
10749        let req = push_req(vec![PathBuf::from("/a")]);
10750        let outcome = |result| worktree_push::WorktreeOutcome {
10751            path: PathBuf::from("/x"),
10752            branch: Some("x".into()),
10753            remote: "origin".into(),
10754            remote_branch: "x".into(),
10755            result,
10756        };
10757        let outcomes = vec![
10758            outcome(worktree_push::PushResult::Pushed { forced: true }),
10759            outcome(worktree_push::PushResult::Pushed { forced: false }),
10760            outcome(worktree_push::PushResult::Created),
10761            outcome(worktree_push::PushResult::Rejected {
10762                detail: "stale info".into(),
10763                stale: true,
10764            }),
10765            outcome(worktree_push::PushResult::Rejected {
10766                detail: "pre-receive hook declined".into(),
10767                stale: false,
10768            }),
10769        ];
10770        let logs = capture_info(|| log_push_execute(&req, &outcomes));
10771        assert!(logs.contains("push execute"), "{logs}");
10772        assert!(logs.contains("pushed=2"), "{logs}");
10773        assert!(logs.contains("forced=1"), "{logs}");
10774        assert!(logs.contains("created=1"), "{logs}");
10775        assert!(logs.contains("rejected=2"), "{logs}");
10776        assert!(
10777            logs.contains("stale_rejected=1"),
10778            "a lease refusal is the interesting half of a rejection: {logs}"
10779        );
10780    }
10781
10782    #[test]
10783    fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10784        let dir = tempfile::tempdir().unwrap();
10785        let path = canonical(dir.path());
10786
10787        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10788        assert!(!quiet.pushing);
10789        let json = serde_json::to_value(&quiet).unwrap();
10790        assert!(
10791            json.get("pushing").is_none(),
10792            "an idle row stays byte-identical for an older client: {json}"
10793        );
10794
10795        let busy = worktree_entry(
10796            &path,
10797            true,
10798            &HashMap::new(),
10799            &InFlight {
10800                pushing: [path.clone()].into(),
10801                rebasing: HashSet::new(),
10802            },
10803        );
10804        assert!(busy.pushing, "the registry's transient mark rides through");
10805        assert!(
10806            !busy.rebasing,
10807            "the two cues are independent — a push must not read as a rebase"
10808        );
10809        assert_eq!(
10810            serde_json::to_value(&busy).unwrap()["pushing"],
10811            serde_json::json!(true)
10812        );
10813    }
10814
10815    #[test]
10816    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10817        assert_eq!(operation_slug(RepositoryState::Clean), None);
10818        assert_eq!(
10819            operation_slug(RepositoryState::Rebase).as_deref(),
10820            Some("rebase")
10821        );
10822        assert_eq!(
10823            operation_slug(RepositoryState::RebaseMerge).as_deref(),
10824            Some("rebase"),
10825            "the merge-backend rebase is still just a rebase to the user"
10826        );
10827        assert_eq!(
10828            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10829            Some("rebase-interactive")
10830        );
10831        assert_eq!(
10832            operation_slug(RepositoryState::Merge).as_deref(),
10833            Some("merge")
10834        );
10835        assert_eq!(
10836            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10837            Some("cherry-pick")
10838        );
10839        assert_eq!(
10840            operation_slug(RepositoryState::RevertSequence).as_deref(),
10841            Some("revert")
10842        );
10843        assert_eq!(
10844            operation_slug(RepositoryState::Bisect).as_deref(),
10845            Some("bisect")
10846        );
10847        assert_eq!(
10848            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10849            Some("apply-mailbox")
10850        );
10851    }
10852
10853    #[test]
10854    fn git_status_omits_operation_for_a_clean_worktree() {
10855        let dir = tempfile::tempdir().unwrap();
10856        let _repo = diverging_repo(dir.path());
10857        assert_eq!(
10858            git_status(dir.path()).operation,
10859            None,
10860            "a clean worktree carries no operation, so the field stays off the wire"
10861        );
10862    }
10863
10864    #[test]
10865    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10866        let main_dir = tempfile::tempdir().unwrap();
10867        let repo = init_repo(main_dir.path());
10868        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10869        repo.set_head("refs/heads/main").unwrap();
10870        let path = canonical(main_dir.path());
10871
10872        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10873        assert!(!quiet.rebasing);
10874        // Byte-identical for a pre-#1415 client: neither new field is serialized.
10875        let json = serde_json::to_value(&quiet).unwrap();
10876        assert!(json.get("rebasing").is_none(), "{json}");
10877        assert!(json.get("operation").is_none(), "{json}");
10878
10879        let busy = worktree_entry(
10880            &path,
10881            true,
10882            &HashMap::new(),
10883            &InFlight {
10884                rebasing: std::iter::once(path.clone()).collect(),
10885                pushing: HashSet::new(),
10886            },
10887        );
10888        assert!(busy.rebasing, "the registry's transient mark rides through");
10889        assert_eq!(
10890            serde_json::to_value(&busy).unwrap()["rebasing"],
10891            serde_json::Value::Bool(true)
10892        );
10893    }
10894
10895    #[test]
10896    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10897        assert_eq!(note_kinds(&[]), "-");
10898        assert_eq!(
10899            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10900            "dirty,untracked"
10901        );
10902    }
10903
10904    #[test]
10905    fn is_self_close_true_only_when_requester_owns_an_open_window() {
10906        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10907        assert!(is_self_close(Some("w1"), &windows));
10908        assert!(
10909            !is_self_close(Some("w3"), &windows),
10910            "requester owns no window"
10911        );
10912        assert!(!is_self_close(None, &windows), "no requester");
10913        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10914    }
10915
10916    #[test]
10917    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10918        let logs = capture_info(|| {
10919            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10920            assert_eq!(reply, json!({ "removed": true }));
10921        });
10922        assert!(
10923            logs.contains("worktrees close: linked worktree pruned"),
10924            "a successful prune must log an INFO audit line, got: {logs}"
10925        );
10926        assert!(
10927            logs.contains("/wt/feature"),
10928            "the target path must ride the line, got: {logs}"
10929        );
10930    }
10931
10932    #[test]
10933    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10934        // The #1403 fix: an already-removed worktree still replies `removed: true`
10935        // (the row should go) but must NOT log the `pruned` line — it logs the
10936        // distinct `already-gone` outcome so the audit trail stops conflating the
10937        // two.
10938        let logs = capture_info(|| {
10939            let reply =
10940                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10941            assert_eq!(reply, json!({ "removed": true }));
10942        });
10943        assert!(
10944            logs.contains("worktrees close: nothing to prune, worktree already removed"),
10945            "an already-gone close must log its own outcome, got: {logs}"
10946        );
10947        assert!(
10948            !logs.contains("linked worktree pruned"),
10949            "an already-gone close must not claim it pruned, got: {logs}"
10950        );
10951    }
10952
10953    #[test]
10954    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10955        // ERROR is more severe than the INFO cap, so `capture_info` records it.
10956        let logs = capture_info(|| {
10957            let err = log_close_error(
10958                Path::new("/wt/feature"),
10959                "safety check",
10960                anyhow!("not a git worktree"),
10961            );
10962            assert_eq!(
10963                err.to_string(),
10964                "not a git worktree",
10965                "err propagates unchanged"
10966            );
10967        });
10968        assert!(
10969            logs.contains("worktrees close: safety check failed"),
10970            "a failed phase must log an ERROR audit line, got: {logs}"
10971        );
10972        assert!(
10973            logs.contains("not a git worktree"),
10974            "the cause must ride the line, got: {logs}"
10975        );
10976        assert!(
10977            logs.contains("/wt/feature"),
10978            "the target path must ride the line, got: {logs}"
10979        );
10980    }
10981
10982    #[test]
10983    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10984        let logs = capture_info(|| {
10985            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10986            assert!(err.is_err(), "a prune failure must propagate");
10987        });
10988        assert!(
10989            logs.contains("worktrees close: worktree prune failed"),
10990            "a prune failure must log a WARN audit line, got: {logs}"
10991        );
10992        assert!(
10993            logs.contains("locked"),
10994            "the failure cause must ride the line, got: {logs}"
10995        );
10996    }
10997
10998    #[test]
10999    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
11000        let git = GitSafety {
11001            is_main: false,
11002            removable: true,
11003            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
11004            info: vec![],
11005        };
11006        let logs = capture_info(|| {
11007            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
11008        });
11009        assert!(
11010            logs.contains("worktrees close: safety check"),
11011            "phase-1 must log a safety-check line, got: {logs}"
11012        );
11013        assert!(
11014            logs.contains("/wt/feature"),
11015            "the path must ride the line, got: {logs}"
11016        );
11017        assert!(
11018            logs.contains("window_key=\"win-42\""),
11019            "the owning window key must ride the line, got: {logs}"
11020        );
11021        assert!(logs.contains("removable=true"), "got: {logs}");
11022        assert!(logs.contains("is_main=false"), "got: {logs}");
11023        assert!(logs.contains("open=true"), "got: {logs}");
11024        assert!(
11025            logs.contains("risks=dirty,untracked"),
11026            "the blocking risk kinds must ride the line, got: {logs}"
11027        );
11028    }
11029
11030    #[test]
11031    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
11032        let git = GitSafety {
11033            is_main: false,
11034            removable: true,
11035            risks: vec![],
11036            info: vec![],
11037        };
11038        let logs = capture_info(|| {
11039            log_safety_check(Path::new("/wt/feature"), None, &git, false);
11040        });
11041        assert!(
11042            logs.contains("window_key=\"-\""),
11043            "no owning window → dash, got: {logs}"
11044        );
11045        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11046    }
11047
11048    #[test]
11049    fn log_executing_logs_the_routing_decision() {
11050        let logs = capture_info(|| {
11051            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11052        });
11053        assert!(
11054            logs.contains("worktrees close: executing"),
11055            "phase-2 must log the execute routing, got: {logs}"
11056        );
11057        assert!(
11058            logs.contains("requester=\"win-7\""),
11059            "the requester key must ride the line, got: {logs}"
11060        );
11061        assert!(logs.contains("remove=true"), "got: {logs}");
11062        assert!(logs.contains("self_close=false"), "got: {logs}");
11063        assert!(logs.contains("cross_window=3"), "got: {logs}");
11064    }
11065
11066    #[test]
11067    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11068        let logs = capture_info(|| {
11069            log_close_abort(
11070                Path::new("/wt/feature"),
11071                &anyhow!("window(s) did not close in time: win-9"),
11072            );
11073        });
11074        assert!(
11075            logs.contains("worktrees close: aborted"),
11076            "an abort must log a WARN audit line, got: {logs}"
11077        );
11078        assert!(
11079            logs.contains("/wt/feature"),
11080            "the path must ride the line, got: {logs}"
11081        );
11082        assert!(
11083            logs.contains("win-9"),
11084            "the still-open window must ride the line, got: {logs}"
11085        );
11086    }
11087
11088    #[test]
11089    fn log_window_closed_logs_the_no_removal_outcome() {
11090        let logs = capture_info(|| {
11091            log_window_closed(Path::new("/wt/feature"));
11092        });
11093        assert!(
11094            logs.contains("worktrees close: window closed, no removal"),
11095            "a remove:false close must log the no-removal outcome, got: {logs}"
11096        );
11097        assert!(
11098            logs.contains("/wt/feature"),
11099            "the path must ride the line, got: {logs}"
11100        );
11101    }
11102
11103    #[test]
11104    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11105        // The reorder (#1315) must still fully remove a worktree: both the
11106        // checked-out directory *and* the admin metadata git tracks it by, so it
11107        // no longer appears in `Repository::worktrees()`.
11108        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11109        let admin = main.path().join(".git").join("worktrees").join("feature");
11110        assert!(admin.exists(), "admin metadata should exist before removal");
11111
11112        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11113
11114        assert!(!wt_path.exists(), "the working directory should be gone");
11115        assert!(!admin.exists(), "the admin metadata should be pruned");
11116        let main_repo = Repository::open(main.path()).unwrap();
11117        assert_eq!(
11118            main_repo.worktrees().unwrap().len(),
11119            0,
11120            "git should no longer track the worktree"
11121        );
11122    }
11123
11124    #[test]
11125    fn remove_worktree_recovers_a_half_removed_orphan() {
11126        // The exact #1315 leftover: the old ordering deleted the admin metadata
11127        // first, then failed to rmdir the working tree, orphaning the directory
11128        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
11129        // rather than error with "not a git worktree".
11130        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11131        let admin = main.path().join(".git").join("worktrees").join("feature");
11132        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
11133        std::fs::remove_dir_all(&admin).unwrap();
11134        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11135        assert!(
11136            Repository::open(&wt_path).is_err(),
11137            "the orphan should not open as a repo"
11138        );
11139
11140        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11141        assert!(
11142            !wt_path.exists(),
11143            "the leftover directory should be removed"
11144        );
11145    }
11146
11147    /// A linked worktree whose working directory has been deleted out-of-band,
11148    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
11149    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
11150    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
11151    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
11152    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
11153    /// admin dir)`.
11154    fn orphaned_admin_worktree() -> (
11155        tempfile::TempDir,
11156        PathBuf,
11157        tempfile::TempDir,
11158        PathBuf,
11159        PathBuf,
11160    ) {
11161        let main_dir = tempfile::tempdir().unwrap();
11162        let main_root = main_dir.path().canonicalize().unwrap();
11163        let repo = init_repo(&main_root);
11164        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11165        repo.set_head("refs/heads/trunk").unwrap();
11166        let wt_parent = tempfile::tempdir().unwrap();
11167        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11168        add_worktree(&repo, a, &wt_path, "feature");
11169        let admin = main_root.join(".git").join("worktrees").join("feature");
11170        assert!(admin.exists(), "admin metadata exists before the orphaning");
11171        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
11172        std::fs::remove_dir_all(&wt_path).unwrap();
11173        (main_dir, main_root, wt_parent, wt_path, admin)
11174    }
11175
11176    fn window_on(folder: &Path) -> WindowEntry {
11177        WindowEntry {
11178            key: "w".to_string(),
11179            folders: vec![folder.to_path_buf()],
11180            repo: None,
11181            title: None,
11182            pid: None,
11183            last_seen: Utc::now(),
11184        }
11185    }
11186
11187    #[test]
11188    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11189        // #1403: working tree gone, admin present. An external worktree shares no
11190        // ancestor with its repo, so the owner is found via a live window on the
11191        // main repo — the same window whose repo enumeration showed the stuck row.
11192        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11193
11194        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11195
11196        assert_eq!(
11197            removed,
11198            Removal::Pruned,
11199            "the orphaned admin must be pruned"
11200        );
11201        assert!(!admin.exists(), "the admin metadata should be gone");
11202        let main_repo = Repository::open(&main_root).unwrap();
11203        assert!(
11204            main_repo.worktrees().unwrap().is_empty(),
11205            "git should no longer track the orphaned worktree"
11206        );
11207    }
11208
11209    #[test]
11210    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11211        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
11212        // worktrees/<name>` shape that produced the reported orphans) is located
11213        // by walking the gone path's ancestors — no registered window needed.
11214        let main_dir = tempfile::tempdir().unwrap();
11215        let main_root = main_dir.path().canonicalize().unwrap();
11216        let repo = init_repo(&main_root);
11217        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11218        repo.set_head("refs/heads/trunk").unwrap();
11219        // git2's `worktree()` creates the leaf but not intermediate parents.
11220        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11221        let wt_path = main_root.join(".nested").join("feature-wt");
11222        add_worktree(&repo, a, &wt_path, "feature");
11223        let admin = main_root.join(".git").join("worktrees").join("feature");
11224        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11225
11226        let removed = remove_worktree(&wt_path, &[]).unwrap();
11227
11228        assert_eq!(removed, Removal::Pruned);
11229        assert!(!admin.exists(), "the admin metadata should be gone");
11230    }
11231
11232    #[test]
11233    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11234        // The other side of #1403: working tree gone AND admin already pruned. No
11235        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
11236        // never a `pruned` lie.
11237        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11238        // Prune the admin entry too, so nothing remains to remove.
11239        std::fs::remove_dir_all(&admin).unwrap();
11240
11241        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11242
11243        assert_eq!(removed, Removal::AlreadyGone);
11244    }
11245
11246    #[test]
11247    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11248        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11249        // External worktree: no ancestor is the repo, so only the window feed finds
11250        // it. The main root rides through, deduped to a single entry.
11251        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11252        assert!(
11253            roots.contains(&main_root),
11254            "the owning main repo must be a candidate, got: {roots:?}"
11255        );
11256    }
11257
11258    #[test]
11259    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11260        // A candidate root that does not open as a repo (a stale registry folder,
11261        // a deleted repo) is skipped rather than fatal; the real owner that
11262        // follows still prunes.
11263        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11264        let junk = tempfile::tempdir().unwrap();
11265
11266        let removed =
11267            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11268
11269        assert_eq!(removed, Removal::Pruned);
11270        assert!(
11271            !admin.exists(),
11272            "the real owner must still prune the orphan"
11273        );
11274    }
11275
11276    #[test]
11277    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11278        // A candidate that opens as a repo but is a *linked* worktree carries no
11279        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
11280        // is the real owner. (A live sibling worktree is exactly such a candidate.)
11281        let main_dir = tempfile::tempdir().unwrap();
11282        let main_root = main_dir.path().canonicalize().unwrap();
11283        let repo = init_repo(&main_root);
11284        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11285        repo.set_head("refs/heads/trunk").unwrap();
11286        let wt_parent = tempfile::tempdir().unwrap();
11287        let wt_root = wt_parent.path().canonicalize().unwrap();
11288        let orphan = wt_root.join("orphan-wt");
11289        let sibling = wt_root.join("sibling-wt");
11290        add_worktree(&repo, a, &orphan, "orphan");
11291        add_worktree(&repo, a, &sibling, "sibling");
11292        let admin = main_root.join(".git").join("worktrees").join("orphan");
11293        std::fs::remove_dir_all(&orphan).unwrap();
11294
11295        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
11296        // main repo second (the actual owner).
11297        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11298
11299        assert_eq!(removed, Removal::Pruned);
11300        assert!(
11301            !admin.exists(),
11302            "the orphan's admin metadata must be pruned"
11303        );
11304    }
11305
11306    #[test]
11307    fn prune_orphaned_admin_refuses_a_locked_orphan() {
11308        // Locking is an admin-dir file, independent of the (gone) checkout, so an
11309        // orphaned worktree can still be locked. The prune must refuse it —
11310        // "unlock first" — rather than force past, mirroring the live path.
11311        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11312        let main_repo = Repository::open(&main_root).unwrap();
11313        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11314        main_repo
11315            .find_worktree(&name)
11316            .unwrap()
11317            .lock(Some("in use"))
11318            .unwrap();
11319
11320        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11321
11322        assert!(
11323            err.to_string().contains("locked"),
11324            "a locked orphan must be refused, got: {err:#}"
11325        );
11326        assert!(admin.exists(), "a refused prune must leave the admin entry");
11327    }
11328
11329    #[test]
11330    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11331        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11332        // A live worktree: gitlink resolves → not an orphan.
11333        assert!(!is_orphaned_worktree(&wt_path));
11334        // The main checkout has a `.git` directory → not an orphan.
11335        assert!(!is_orphaned_worktree(main.path()));
11336        // Drop the admin metadata → the gitlink now dangles → orphan.
11337        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11338            .unwrap();
11339        assert!(is_orphaned_worktree(&wt_path));
11340    }
11341
11342    #[test]
11343    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11344        let tmp = tempfile::tempdir().unwrap();
11345        let missing = tmp.path().join("gone");
11346        assert!(remove_dir_all_retrying(&missing).is_ok());
11347    }
11348
11349    #[test]
11350    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11351        use std::io::Error;
11352        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11353            assert!(
11354                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11355                "errno {errno} is the concurrent-writer race and must be retried"
11356            );
11357        }
11358        // A hard failure must surface immediately rather than burn the backoff
11359        // waiting for a condition that will never clear.
11360        for errno in [
11361            nix::libc::EACCES,
11362            nix::libc::EPERM,
11363            nix::libc::EROFS,
11364            nix::libc::ENOTDIR,
11365        ] {
11366            assert!(
11367                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11368                "errno {errno} is permanent and must not be retried"
11369            );
11370        }
11371        // Not from the OS at all, so there is no errno to classify.
11372        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11373    }
11374
11375    #[test]
11376    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11377        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
11378        // race, so it must fail on the first attempt with the original cause
11379        // attached, leaving the path untouched.
11380        let tmp = tempfile::tempdir().unwrap();
11381        let file = tmp.path().join("not-a-directory");
11382        std::fs::write(&file, b"x").unwrap();
11383
11384        let mut attempts = 0;
11385        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11386            attempts += 1;
11387            std::fs::remove_dir_all(&file)
11388        })
11389        .unwrap_err();
11390
11391        assert_eq!(attempts, 1, "a permanent error must not be retried");
11392        assert!(
11393            err.to_string()
11394                .contains("failed to remove worktree directory"),
11395            "unexpected error: {err:#}"
11396        );
11397        assert!(err.source().is_some(), "the io::Error cause is preserved");
11398        assert!(file.exists());
11399    }
11400
11401    #[test]
11402    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11403        // A writer that never quiesces: every sweep re-finds the directory
11404        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
11405        // than the loop spinning forever.
11406        let tmp = tempfile::tempdir().unwrap();
11407        let mut attempts = 0;
11408        let backoff = [Duration::ZERO, Duration::ZERO];
11409        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11410            attempts += 1;
11411            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11412        })
11413        .unwrap_err();
11414
11415        // One attempt per delay, plus the initial one.
11416        assert_eq!(attempts, backoff.len() + 1);
11417        assert!(
11418            err.to_string()
11419                .contains("failed to remove worktree directory"),
11420            "unexpected error: {err:#}"
11421        );
11422    }
11423
11424    #[test]
11425    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11426        // The #1315 happy path, deterministically: the race clears partway through
11427        // the schedule and the removal then succeeds.
11428        let tmp = tempfile::tempdir().unwrap();
11429        let mut attempts = 0;
11430        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11431            attempts += 1;
11432            if attempts < 3 {
11433                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11434            } else {
11435                Ok(())
11436            }
11437        });
11438        assert!(result.is_ok(), "{result:?}");
11439        assert_eq!(attempts, 3);
11440    }
11441
11442    #[test]
11443    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11444        // A `.git` file that is readable but carries no `gitdir:` pointer is not
11445        // something we may delete.
11446        let tmp = tempfile::tempdir().unwrap();
11447        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11448        assert!(!is_orphaned_worktree(tmp.path()));
11449    }
11450
11451    #[test]
11452    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11453        // Neither a repo nor an orphan: refuse it rather than recursively deleting
11454        // whatever directory was passed in.
11455        let tmp = tempfile::tempdir().unwrap();
11456        let plain = tmp.path().join("plain");
11457        std::fs::create_dir(&plain).unwrap();
11458
11459        let err = remove_worktree(&plain, &[]).unwrap_err();
11460
11461        assert!(
11462            err.to_string().contains("not a git worktree"),
11463            "unexpected error: {err:#}"
11464        );
11465        assert!(plain.exists(), "a non-worktree path must be left alone");
11466    }
11467
11468    #[test]
11469    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11470        // Acceptance criterion (#1315): a language server / cargo still writing
11471        // into `target/` as the window closes makes the recursive rmdir race with
11472        // "Directory not empty". A background thread reproduces that by
11473        // repopulating `target/` for a bounded window; removal must retry past it
11474        // and still succeed once the writer stops.
11475        use std::sync::atomic::{AtomicBool, Ordering};
11476        use std::sync::Arc;
11477
11478        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11479        // Created once, here — never inside the writer loop. `create_dir_all`
11480        // rebuilds every *parent* component, so calling it per iteration let the
11481        // writer resurrect the worktree root the instant removal won the race,
11482        // failing the final assertion on a directory removal had correctly
11483        // deleted and the test itself put back (#1410).
11484        let nested = wt_path.join("target").join("nested");
11485        std::fs::create_dir_all(&nested).unwrap();
11486
11487        let stop = Arc::new(AtomicBool::new(false));
11488        let writer_stop = Arc::clone(&stop);
11489        let writer_dir = nested;
11490        let writer = std::thread::spawn(move || {
11491            let mut n = 0u64;
11492            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
11493            // stop so a later removal pass finds the directory quiescent.
11494            let deadline = std::time::Instant::now() + Duration::from_millis(400);
11495            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11496                // Best-effort, and deliberately creating no directory: `fs::write`
11497                // is `File::create`, which never makes parents. While `target/`
11498                // survives these keep it non-empty — the ENOTEMPTY removal has to
11499                // retry past — and once removal wins they simply fail with ENOENT.
11500                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11501                n += 1;
11502            }
11503        });
11504
11505        let result = remove_worktree(&wt_path, &[]);
11506        stop.store(true, Ordering::Relaxed);
11507        writer.join().unwrap();
11508
11509        assert!(
11510            result.is_ok(),
11511            "removal should retry past the writer: {result:?}"
11512        );
11513        assert!(!wt_path.exists(), "the worktree directory should be gone");
11514    }
11515
11516    #[tokio::test]
11517    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11518        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11519        // An untracked file in the worktree would be lost on removal.
11520        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11521
11522        let svc = WorktreesService::new();
11523        let report = svc
11524            .handle("close", json!({ "path": wt_path, "remove": true }))
11525            .await
11526            .unwrap();
11527        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11528        assert!(
11529            risks
11530                .iter()
11531                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11532            "expected an untracked risk: {report}"
11533        );
11534        // Still removable — the risk only means "confirm first", not "refuse".
11535        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11536        // The unconfirmed check has no side effects.
11537        assert!(wt_path.exists());
11538    }
11539
11540    #[tokio::test]
11541    async fn close_confirmed_removes_a_dirty_worktree() {
11542        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11543        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11544        let svc = WorktreesService::new();
11545        // With confirmation, the risks are overridden and removal proceeds.
11546        let reply = svc
11547            .handle(
11548                "close",
11549                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11550            )
11551            .await
11552            .unwrap();
11553        assert_eq!(reply, json!({ "removed": true }));
11554        assert!(!wt_path.exists());
11555    }
11556
11557    #[tokio::test]
11558    async fn close_refuses_to_remove_the_main_working_tree() {
11559        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11560        let svc = WorktreesService::new();
11561        // Phase 1: the main tree reports not-removable, marked main.
11562        let report = svc
11563            .handle("close", json!({ "path": main.path(), "remove": true }))
11564            .await
11565            .unwrap();
11566        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11567        assert_eq!(
11568            report.get("removable").and_then(Value::as_bool),
11569            Some(false)
11570        );
11571        // Phase 2: even a confirmed delete of the main tree is refused
11572        // defensively, and the directory is untouched.
11573        assert!(svc
11574            .handle(
11575                "close",
11576                json!({ "path": main.path(), "remove": true, "confirmed": true }),
11577            )
11578            .await
11579            .is_err());
11580        assert!(main.path().exists());
11581    }
11582
11583    #[tokio::test]
11584    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11585        // The case a naive impl would wrongly protect: a linked worktree checked
11586        // out on `main` (the default branch) is *still a linked worktree*, so it
11587        // is fully deletable — and `main` survives (removal never deletes a branch).
11588        let main_dir = tempfile::tempdir().unwrap();
11589        let repo = init_repo(main_dir.path());
11590        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11591        repo.set_head("refs/heads/trunk").unwrap();
11592        let wt_parent = tempfile::tempdir().unwrap();
11593        let wt_path = wt_parent.path().join("main-wt");
11594        add_worktree(&repo, a, &wt_path, "main");
11595
11596        let svc = WorktreesService::new();
11597        let reply = svc
11598            .handle(
11599                "close",
11600                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11601            )
11602            .await
11603            .unwrap();
11604        assert_eq!(reply, json!({ "removed": true }));
11605        assert!(!wt_path.exists());
11606        // The `main` branch is untouched by the worktree removal.
11607        assert!(
11608            repo.find_branch("main", git2::BranchType::Local).is_ok(),
11609            "the default branch must survive worktree removal"
11610        );
11611    }
11612
11613    #[tokio::test]
11614    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11615        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11616        let svc = WorktreesService::new();
11617        // First removal succeeds.
11618        svc.handle(
11619            "close",
11620            json!({ "path": wt_path, "remove": true, "confirmed": true }),
11621        )
11622        .await
11623        .unwrap();
11624        // A second confirmed close of the now-missing path is a clean success,
11625        // not an error (a stale snapshot must not crash).
11626        let reply = svc
11627            .handle(
11628                "close",
11629                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11630            )
11631            .await
11632            .unwrap();
11633        assert_eq!(reply, json!({ "removed": true }));
11634    }
11635
11636    #[tokio::test]
11637    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11638        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
11639        // behind so the tree view keeps showing a `prunable` row. Closing it must
11640        // actually prune the admin metadata (via the registered window's repo), not
11641        // report a `pruned` no-op that leaves the row stuck.
11642        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11643        let svc = WorktreesService::new();
11644        // A live window on the main repo — the vantage point the stuck row is
11645        // enumerated from, and the one the prune locates the owner through.
11646        svc.handle(
11647            "register",
11648            register_payload("main-w", None, &main_root.display().to_string()),
11649        )
11650        .await
11651        .unwrap();
11652
11653        // Before: the orphaned linked worktree still shows alongside the main tree.
11654        let before = svc.handle("tree", Value::Null).await.unwrap();
11655        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11656        assert_eq!(
11657            worktrees_before, 2,
11658            "the orphaned row is present before close"
11659        );
11660
11661        let reply = svc
11662            .handle(
11663                "close",
11664                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11665            )
11666            .await
11667            .unwrap();
11668        assert_eq!(reply, json!({ "removed": true }));
11669
11670        // After: admin metadata pruned and the row is gone from the tree view.
11671        assert!(!admin.exists(), "the admin metadata must be pruned");
11672        let after = svc.handle("tree", Value::Null).await.unwrap();
11673        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11674        assert_eq!(worktrees_after, 1, "only the main working tree remains");
11675    }
11676
11677    #[tokio::test]
11678    async fn close_safety_check_detects_detached_head_unreachable_commits() {
11679        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11680        // In the worktree, commit onto a detached HEAD so the new commit is
11681        // reachable from no ref — it would be GC'd on removal.
11682        let wt_repo = Repository::open(&wt_path).unwrap();
11683        let parent_oid = wt_repo.head().unwrap().target().unwrap();
11684        let parent = wt_repo.find_commit(parent_oid).unwrap();
11685        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11686        wt_repo.set_head_detached(orphan).unwrap();
11687
11688        let svc = WorktreesService::new();
11689        let report = svc
11690            .handle("close", json!({ "path": wt_path, "remove": true }))
11691            .await
11692            .unwrap();
11693        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11694        assert!(
11695            risks
11696                .iter()
11697                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11698            "expected an unreachable-commits risk: {report}"
11699        );
11700    }
11701
11702    #[tokio::test]
11703    async fn close_self_close_removes_when_the_requester_owns_the_target() {
11704        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11705        let svc = WorktreesService::new();
11706        // The requesting window itself has the worktree open: it is the only
11707        // owning window, so there is nothing to wait on — remove and reply, and
11708        // the extension closes its own window on `ok`.
11709        svc.handle(
11710            "register",
11711            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11712        )
11713        .await
11714        .unwrap();
11715        let reply = svc
11716            .handle(
11717                "close",
11718                json!({
11719                    "path": wt_path,
11720                    "remove": true,
11721                    "confirmed": true,
11722                    "requester_key": "w1",
11723                }),
11724            )
11725            .await
11726            .unwrap();
11727        assert_eq!(reply, json!({ "removed": true }));
11728        assert!(!wt_path.exists());
11729    }
11730
11731    #[tokio::test]
11732    async fn close_safety_check_surfaces_the_owning_window() {
11733        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11734        let svc = WorktreesService::new();
11735        // A multi-root window owns the target: the report surfaces its key and
11736        // folder count so the extension can warn "all N folders will close".
11737        svc.handle(
11738            "register",
11739            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11740        )
11741        .await
11742        .unwrap();
11743        let report = svc
11744            .handle("close", json!({ "path": wt_path, "remove": true }))
11745            .await
11746            .unwrap();
11747        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11748        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11749        assert_eq!(
11750            report.get("window_folder_count").and_then(Value::as_u64),
11751            Some(2)
11752        );
11753    }
11754
11755    #[tokio::test]
11756    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11757        let svc = WorktreesService::new();
11758        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11759            .await
11760            .unwrap();
11761        // No directive → a plain `{ known: true }`, byte-identical to before.
11762        assert_eq!(
11763            svc.handle("heartbeat", json!({ "key": "w1" }))
11764                .await
11765                .unwrap(),
11766            json!({ "known": true })
11767        );
11768        // Marked → the next heartbeat carries `close: true`, exactly once.
11769        svc.registry.mark_close_pending("w1");
11770        assert_eq!(
11771            svc.handle("heartbeat", json!({ "key": "w1" }))
11772                .await
11773                .unwrap(),
11774            json!({ "known": true, "close": true })
11775        );
11776        assert_eq!(
11777            svc.handle("heartbeat", json!({ "key": "w1" }))
11778                .await
11779                .unwrap(),
11780            json!({ "known": true })
11781        );
11782    }
11783
11784    // --- Reload op (#1417) -------------------------------------------------
11785
11786    #[tokio::test]
11787    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11788        let svc = WorktreesService::new();
11789        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11790            .await
11791            .unwrap();
11792        // Nothing pending → `reload` is absent, so a companion that predates
11793        // #1417 sees a byte-identical reply.
11794        assert_eq!(
11795            svc.handle("heartbeat", json!({ "key": "w1" }))
11796                .await
11797                .unwrap(),
11798            json!({ "known": true })
11799        );
11800        // Marked → the next heartbeat carries `reload: true`, exactly once.
11801        svc.registry.mark_reload_pending("w1");
11802        assert_eq!(
11803            svc.handle("heartbeat", json!({ "key": "w1" }))
11804                .await
11805                .unwrap(),
11806            json!({ "known": true, "reload": true })
11807        );
11808        assert_eq!(
11809            svc.handle("heartbeat", json!({ "key": "w1" }))
11810                .await
11811                .unwrap(),
11812            json!({ "known": true })
11813        );
11814    }
11815
11816    #[tokio::test]
11817    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11818        let svc = WorktreesService::new();
11819        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11820            .await
11821            .unwrap();
11822        // Independent `if`s, not an `else`: both fields ride the same reply and
11823        // both are consumed, so neither directive can be stranded by the other.
11824        // The companion resolves the collision by checking `close` first.
11825        svc.registry.mark_close_pending("w1");
11826        svc.registry.mark_reload_pending("w1");
11827        assert_eq!(
11828            svc.handle("heartbeat", json!({ "key": "w1" }))
11829                .await
11830                .unwrap(),
11831            json!({ "known": true, "close": true, "reload": true })
11832        );
11833        assert_eq!(
11834            svc.handle("heartbeat", json!({ "key": "w1" }))
11835                .await
11836                .unwrap(),
11837            json!({ "known": true })
11838        );
11839    }
11840
11841    #[tokio::test]
11842    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11843        let svc = WorktreesService::new();
11844        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11845            .await
11846            .unwrap();
11847        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11848            .await
11849            .unwrap();
11850
11851        // A key with no live window is reported, never an error: a window
11852        // closing between the client listing and sending is routine.
11853        let reply = svc
11854            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11855            .await
11856            .unwrap();
11857        assert_eq!(
11858            reply,
11859            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11860        );
11861
11862        // Both live targets now have a directive waiting; the unknown one does
11863        // not (the daemon must not resurrect a key it never knew).
11864        assert!(svc.registry.take_reload_pending("w1"));
11865        assert!(svc.registry.take_reload_pending("w2"));
11866        assert!(!svc.registry.take_reload_pending("ghost"));
11867    }
11868
11869    #[tokio::test]
11870    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11871        let svc = WorktreesService::new();
11872        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11873            .await
11874            .unwrap();
11875
11876        // A client repeating a key asks for one reload, not two — `requested`
11877        // counts distinct targets so the client's summary cannot overstate.
11878        assert_eq!(
11879            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11880                .await
11881                .unwrap(),
11882            json!({ "requested": 1, "signalled": 1, "unknown": [] })
11883        );
11884
11885        // An empty batch is a no-op success, and a missing field is an empty
11886        // batch — the callers filter their targets before sending.
11887        assert_eq!(
11888            svc.handle("reload", json!({ "target_keys": [] }))
11889                .await
11890                .unwrap(),
11891            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11892        );
11893        assert_eq!(
11894            svc.handle("reload", json!({})).await.unwrap(),
11895            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11896        );
11897    }
11898
11899    #[tokio::test]
11900    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11901        let svc = WorktreesService::new();
11902        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11903            .await
11904            .unwrap();
11905        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11906            .await
11907            .unwrap();
11908
11909        // The end-to-end contract: `reload` marks, the target's own heartbeat
11910        // delivers. Unlike `close`, nothing waits — the op has already returned.
11911        svc.handle("reload", json!({ "target_keys": ["w2"] }))
11912            .await
11913            .unwrap();
11914        assert_eq!(
11915            svc.handle("heartbeat", json!({ "key": "w2" }))
11916                .await
11917                .unwrap(),
11918            json!({ "known": true, "reload": true })
11919        );
11920        // A window that was not a target is untouched.
11921        assert_eq!(
11922            svc.handle("heartbeat", json!({ "key": "w1" }))
11923                .await
11924                .unwrap(),
11925            json!({ "known": true })
11926        );
11927    }
11928
11929    #[tokio::test]
11930    async fn reload_op_treats_an_unregistered_window_as_unknown() {
11931        let svc = WorktreesService::new();
11932        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11933            .await
11934            .unwrap();
11935        svc.handle("unregister", json!({ "key": "w1" }))
11936            .await
11937            .unwrap();
11938        // `list()` reaps on read, so a window that has gone away cannot be
11939        // signalled — it is reported instead.
11940        assert_eq!(
11941            svc.handle("reload", json!({ "target_keys": ["w1"] }))
11942                .await
11943                .unwrap(),
11944            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11945        );
11946    }
11947
11948    #[tokio::test]
11949    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11950        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11951        let svc = Arc::new(WorktreesService::new());
11952        // A *different* window (not the requester) owns the target.
11953        svc.handle(
11954            "register",
11955            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11956        )
11957        .await
11958        .unwrap();
11959
11960        // Drive the destructive close concurrently: it marks w2 to close and
11961        // waits for it to unregister before removing.
11962        let svc2 = svc.clone();
11963        let path = wt_path.clone();
11964        let close = tokio::spawn(async move {
11965            svc2.handle(
11966                "close",
11967                json!({
11968                    "path": path,
11969                    "remove": true,
11970                    "confirmed": true,
11971                    "requester_key": "w1",
11972                }),
11973            )
11974            .await
11975        });
11976
11977        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
11978        // closes its window and unregisters. Poll until the directive appears.
11979        let mut saw_close = false;
11980        for _ in 0..200 {
11981            let hb = svc
11982                .handle("heartbeat", json!({ "key": "w2" }))
11983                .await
11984                .unwrap();
11985            if hb.get("close").and_then(Value::as_bool) == Some(true) {
11986                saw_close = true;
11987                svc.handle("unregister", json!({ "key": "w2" }))
11988                    .await
11989                    .unwrap();
11990                break;
11991            }
11992            tokio::time::sleep(Duration::from_millis(5)).await;
11993        }
11994        assert!(saw_close, "w2 should have been told to close");
11995
11996        // Once w2 has unregistered, the close op removes the worktree.
11997        let reply = close.await.unwrap().unwrap();
11998        assert_eq!(reply, json!({ "removed": true }));
11999        assert!(!wt_path.exists());
12000    }
12001
12002    #[tokio::test]
12003    async fn await_windows_closed_times_out_when_a_window_never_closes() {
12004        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12005        let svc = WorktreesService::new();
12006        svc.handle(
12007            "register",
12008            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12009        )
12010        .await
12011        .unwrap();
12012        // The owning window never unregisters: the wait gives up (with a short
12013        // timeout here) rather than block, and names the still-open window.
12014        let err = await_windows_closed(
12015            &svc.registry,
12016            &wt_path,
12017            Some("w1"),
12018            Duration::from_millis(150),
12019            Duration::from_millis(25),
12020        )
12021        .await
12022        .unwrap_err();
12023        assert!(
12024            err.to_string().contains("w2"),
12025            "error names the window: {err}"
12026        );
12027        // The requester itself is excluded, so a self-only owner returns at once.
12028        await_windows_closed(
12029            &svc.registry,
12030            &wt_path,
12031            Some("w2"),
12032            Duration::from_millis(150),
12033            Duration::from_millis(25),
12034        )
12035        .await
12036        .unwrap();
12037    }
12038
12039    #[tokio::test]
12040    async fn close_window_without_remove_replies_closed_and_never_deletes() {
12041        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12042        let svc = WorktreesService::new();
12043        // "Close Window" on the main tree: no git inspection, no removal.
12044        let reply = svc
12045            .handle("close", json!({ "path": main.path(), "remove": false }))
12046            .await
12047            .unwrap();
12048        assert_eq!(reply, json!({ "closed": true }));
12049        assert!(main.path().exists());
12050    }
12051
12052    #[tokio::test]
12053    async fn close_safety_check_flags_modified_tracked_files() {
12054        // A tracked file, checked out into the linked worktree, then modified —
12055        // its content is lost on removal, so it is a `dirty` risk (distinct from
12056        // the untracked case).
12057        let main_dir = tempfile::tempdir().unwrap();
12058        let repo = init_repo(main_dir.path());
12059        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12060        repo.set_head("refs/heads/trunk").unwrap();
12061        let wt_parent = tempfile::tempdir().unwrap();
12062        let wt_path = wt_parent.path().join("feature-wt");
12063        add_worktree(&repo, a, &wt_path, "feature");
12064        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12065
12066        let svc = WorktreesService::new();
12067        let report = svc
12068            .handle("close", json!({ "path": wt_path, "remove": true }))
12069            .await
12070            .unwrap();
12071        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12072        assert!(
12073            risks
12074                .iter()
12075                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12076            "expected a dirty risk: {report}"
12077        );
12078    }
12079
12080    #[tokio::test]
12081    async fn close_safety_check_flags_an_in_progress_operation() {
12082        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
12083        // non-Clean (interrupted merge) state — its progress is lost on removal.
12084        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12085        let wt_repo = Repository::open(&wt_path).unwrap();
12086        let head = wt_repo.head().unwrap().target().unwrap();
12087        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12088        assert_ne!(wt_repo.state(), RepositoryState::Clean);
12089
12090        let svc = WorktreesService::new();
12091        let report = svc
12092            .handle("close", json!({ "path": wt_path, "remove": true }))
12093            .await
12094            .unwrap();
12095        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12096        assert!(
12097            risks
12098                .iter()
12099                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12100            "expected an in-progress risk: {report}"
12101        );
12102    }
12103
12104    #[tokio::test]
12105    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12106        // A linked worktree on `feature`, which tracks `origin/feature` and is one
12107        // commit ahead. The unpushed commit is INFO (the branch — and thus the
12108        // commit — survives removal), never a blocking risk.
12109        let main_dir = tempfile::tempdir().unwrap();
12110        let repo = init_repo(main_dir.path());
12111        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12112        repo.set_head("refs/heads/trunk").unwrap();
12113        let a_commit = repo.find_commit(a).unwrap();
12114        repo.branch("feature", &a_commit, false).unwrap();
12115        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12116            .unwrap();
12117        // `feature` advances one commit past `origin/feature`.
12118        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12119        drop(a_commit);
12120        let mut cfg = repo.config().unwrap();
12121        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12122            .unwrap();
12123        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12124            .unwrap();
12125        cfg.set_str("branch.feature.remote", "origin").unwrap();
12126        cfg.set_str("branch.feature.merge", "refs/heads/feature")
12127            .unwrap();
12128        // A worktree on the existing `feature` branch (not created fresh, so it
12129        // keeps the ahead-of-upstream divergence).
12130        let wt_parent = tempfile::tempdir().unwrap();
12131        let wt_path = wt_parent.path().join("feature-wt");
12132        let reference = repo.find_reference("refs/heads/feature").unwrap();
12133        let mut opts = git2::WorktreeAddOptions::new();
12134        opts.reference(Some(&reference));
12135        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12136
12137        let svc = WorktreesService::new();
12138        let report = svc
12139            .handle("close", json!({ "path": wt_path, "remove": true }))
12140            .await
12141            .unwrap();
12142        // Unpushed commits appear as `info`, and the worktree is still cleanly
12143        // removable with no blocking risks.
12144        let info = report.get("info").and_then(Value::as_array).unwrap();
12145        assert!(
12146            info.iter()
12147                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12148            "expected an unpushed info note: {report}"
12149        );
12150        assert!(
12151            report
12152                .get("risks")
12153                .and_then(Value::as_array)
12154                .unwrap()
12155                .is_empty(),
12156            "unpushed commits alone must not block: {report}"
12157        );
12158        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12159    }
12160
12161    #[tokio::test]
12162    async fn close_safety_check_ignores_gitignored_files() {
12163        // With `.gitignore` committed, an ignored artifact is the only worktree
12164        // change — it must not count as untracked (it is regenerable), so the
12165        // worktree stays cleanly removable with no risks.
12166        let main_dir = tempfile::tempdir().unwrap();
12167        let repo = init_repo(main_dir.path());
12168        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12169        repo.set_head("refs/heads/trunk").unwrap();
12170        let wt_parent = tempfile::tempdir().unwrap();
12171        let wt_path = wt_parent.path().join("feature-wt");
12172        add_worktree(&repo, a, &wt_path, "feature");
12173        std::fs::create_dir(wt_path.join("build")).unwrap();
12174        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12175
12176        let svc = WorktreesService::new();
12177        let report = svc
12178            .handle("close", json!({ "path": wt_path, "remove": true }))
12179            .await
12180            .unwrap();
12181        assert!(
12182            report
12183                .get("risks")
12184                .and_then(Value::as_array)
12185                .unwrap()
12186                .is_empty(),
12187            "a gitignored file must not create a risk: {report}"
12188        );
12189        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12190    }
12191
12192    #[tokio::test]
12193    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12194        // The phase-1 check on a path that no longer exists reports it removable
12195        // with no risks (so the idempotent execute proceeds with no dialog).
12196        let svc = WorktreesService::new();
12197        let report = svc
12198            .handle(
12199                "close",
12200                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12201            )
12202            .await
12203            .unwrap();
12204        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12205        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12206        assert!(report
12207            .get("risks")
12208            .and_then(Value::as_array)
12209            .unwrap()
12210            .is_empty());
12211        let info = report.get("info").and_then(Value::as_array).unwrap();
12212        assert!(info
12213            .iter()
12214            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12215    }
12216
12217    #[tokio::test]
12218    async fn close_phase1_errors_on_a_non_git_worktree_path() {
12219        // An existing directory that is *not* a git worktree makes `git_safety`
12220        // fail; the error must propagate (rather than delete an unknown dir),
12221        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
12222        // audit line itself is unit-tested via `log_close_error`.
12223        let dir = tempfile::tempdir().unwrap();
12224        let svc = WorktreesService::new();
12225        let result = svc
12226            .handle("close", json!({ "path": dir.path(), "remove": true }))
12227            .await;
12228        assert!(
12229            result.is_err(),
12230            "a non-git-worktree target must error the safety check, got: {result:?}"
12231        );
12232    }
12233
12234    #[tokio::test]
12235    async fn close_refuses_a_locked_worktree() {
12236        // A locked worktree (git worktree lock) must be refused, not forced past
12237        // (failure mode #6), and left on disk.
12238        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12239        let main_repo = Repository::open(main.path()).unwrap();
12240        main_repo
12241            .find_worktree("feature")
12242            .unwrap()
12243            .lock(Some("under test"))
12244            .unwrap();
12245
12246        let svc = WorktreesService::new();
12247        let err = svc
12248            .handle(
12249                "close",
12250                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12251            )
12252            .await
12253            .unwrap_err();
12254        assert!(
12255            err.to_string().contains("locked"),
12256            "expected a locked error: {err}"
12257        );
12258        assert!(wt_path.exists(), "a locked worktree must not be removed");
12259    }
12260
12261    #[tokio::test]
12262    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12263        // A detached HEAD that still sits on a commit a branch points to loses
12264        // nothing on removal, so it must NOT produce an unreachable-commits risk
12265        // (the false-positive the reachability walk guards against).
12266        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12267        let wt_repo = Repository::open(&wt_path).unwrap();
12268        // The worktree is on `feature`; detach HEAD onto its current tip, which
12269        // the `feature` branch still references.
12270        let tip = wt_repo.head().unwrap().target().unwrap();
12271        wt_repo.set_head_detached(tip).unwrap();
12272        assert!(wt_repo.head_detached().unwrap());
12273
12274        let svc = WorktreesService::new();
12275        let report = svc
12276            .handle("close", json!({ "path": wt_path, "remove": true }))
12277            .await
12278            .unwrap();
12279        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12280        assert!(
12281            !risks
12282                .iter()
12283                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12284            "a detached HEAD reachable from a branch must not be flagged: {report}"
12285        );
12286    }
12287
12288    #[test]
12289    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12290        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12291        let main_repo = Repository::open(main.path()).unwrap();
12292        // The real linked worktree resolves to its registered name.
12293        assert_eq!(
12294            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12295            "feature"
12296        );
12297        // A path that is not one of this repo's worktrees is the defensive
12298        // "not registered" error (the guard behind removal).
12299        let err =
12300            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12301        assert!(
12302            err.to_string().contains("not registered"),
12303            "expected a not-registered error: {err}"
12304        );
12305    }
12306
12307    #[test]
12308    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12309        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
12310        // rather than sinking the whole safety check.
12311        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12312        let repo = Repository::open(&wt_path).unwrap();
12313        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12314        // Confirm the corruption actually breaks status enumeration, so the
12315        // count is exercising the error-degradation branch (not an empty repo).
12316        assert!(
12317            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12318            "a corrupt index should make statuses() fail"
12319        );
12320        assert_eq!(count_dirty_untracked(&repo), (0, 0));
12321    }
12322}