Skip to main content

omni_dev/daemon/services/
worktrees.rs

1//! The worktrees daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`WorktreesRegistry`] under the
4//! daemon's lifecycle and exposes register/heartbeat/unregister/list/tree/open
5//! over the control socket, plus a tray submenu with a per-window "focus" action.
6//! The `open` op (#1266) focuses/opens an arbitrary worktree folder in VS Code
7//! through the **same** launcher path the tray uses, so a socket client (the
8//! companion's double-click) shares the tested guard and launcher resolution
9//! rather than duplicating them.
10//!
11//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
12//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
13//! ops, renders the menu/status, and drives the VS Code launcher. Like the
14//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
15//! secret persisted.
16//!
17//! The adapter also computes the **per-worktree git enrichment** (current
18//! branch, ahead/behind counts, and the parent repository a linked worktree
19//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
20//! of raw folder paths (ADR-0040). The engine stores only what the companion
21//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
22//! under the registry lock.
23//!
24//! The `tree` op (#1265) inverts the data model for the companion's tree view:
25//! from the open windows the adapter derives the **distinct repositories**, then
26//! enumerates **all** of each repo's worktrees (main working tree +
27//! [`Repository::worktrees`]), enriches each (reusing [`git_status`]), tags the
28//! GitHub identity of `origin`, and joins the open windows back on by
29//! canonicalized path. The open-window registry stays the liveness source;
30//! "is a window open on it?" becomes a per-worktree attribute. All of this is
31//! git disk I/O, so it runs on a blocking thread, never under the registry lock.
32
33mod geometry;
34
35use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::process::{Command, Stdio};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::{Arc, Mutex, PoisonError};
40use std::time::{Duration, Instant};
41
42use anyhow::{anyhow, bail, Context, Result};
43use chrono::{DateTime, Utc};
44
45use crate::git::remote::RemoteInfo;
46use crate::git::worktree_batch::Selection;
47use crate::git::worktree_push;
48use crate::git::worktree_rebase;
49use crate::github_rate_limit::{
50    resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
51};
52use crate::pr_status::{
53    EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
54};
55use async_trait::async_trait;
56use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
57use serde::{Deserialize, Serialize};
58use serde_json::{json, Value};
59use tokio::sync::watch;
60use tokio::sync::Mutex as AsyncMutex;
61use tokio::task::JoinHandle;
62use tokio_util::sync::CancellationToken;
63
64use crate::daemon::service::{
65    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
66};
67use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
68
69/// The worktrees service name (the control-socket routing key).
70pub const SERVICE_NAME: &str = "worktrees";
71
72/// The tray submenu title.
73const SUBMENU_TITLE: &str = "Worktrees";
74
75/// Environment override for the VS Code launcher used by the "focus" tray
76/// action, for when the daemon runs under launchd with a minimal `PATH`.
77const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
78
79/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
80/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
81const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
82
83/// Default cadence at which the background task recomputes the tray menu snapshot
84/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
85/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
86/// the GUI thread (which would peg a core and stall shutdown — the #1186
87/// regression); the interval only governs how stale that cached branch/sync state
88/// may be when the menu is opened.
89///
90/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
91/// walk that the subscription-stream coalescing (#1303) never touched — it was
92/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
93/// menu open-latency unchanged (the cache still serves instantly).
94const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
95
96/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
97/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
98fn menu_refresh_interval() -> Duration {
99    crate::daemon::server::duration_secs_from_env(
100        ENV_MENU_REFRESH_INTERVAL,
101        DEFAULT_MENU_REFRESH_INTERVAL,
102    )
103}
104
105/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
106/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
107/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
108const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
109
110/// Default cadence for the PR badge poller while CI is in flight (#1337).
111///
112/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
113/// watching a run — which is exactly this situation. It is affordable because the
114/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
115/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
116/// while something is actually pending.
117const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
118
119/// The ceiling the poller backs off to once every badge is terminal.
120///
121/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
122/// The 30-minute figure is the cross-tool consensus for background PR polling
123/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
124/// defaults). The backoff exists for battery and wakeups rather than budget — at
125/// 1 point per poll the budget never binds.
126const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
127
128/// How long after fresh work (a push, or an added target) the poller holds its
129/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
130/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
131/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
132/// is watched more cheaply. See [`next_pr_poll_delay`].
133const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
134
135/// The ceiling the poller escalates to *while still pending* once
136/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
137/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
138/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
139/// at ~50 calls instead of ~120.
140const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
141
142/// The cadence floor the poller is held to while the shared GitHub budget is
143/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
144/// Five minutes makes the daemon's contribution to an already-strained budget
145/// negligible while still recovering promptly once the window resets — a pause in
146/// all but name. See [`budget_throttled_delay`].
147const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
148
149/// Environment override for [`pr_debounce_interval`] — the settle window the PR
150/// poller waits after the change-notify fires before snapshotting (whole seconds;
151/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
152const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
153
154/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
155///
156/// A VS Code restart unregisters then re-registers its windows one-by-one over
157/// several seconds, each bump waking the poller; a daemon restart re-registers the
158/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
159/// storm into **one** fetch on the final watch set instead of one per window.
160const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
161
162/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
163/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
164fn pr_poll_interval() -> Duration {
165    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
166}
167
168/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
169/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
170fn pr_debounce_interval() -> Duration {
171    crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
172}
173
174/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
175/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
176/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
177const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
178
179/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
180/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
181/// the extension's transient badge fallback — from the daemon means N windows now
182/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
183const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
184
185/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
186/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
187/// `PullRequest` shape unchanged.
188const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
189
190/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
191/// (parity with the extension's `PR_LIST_LIMIT`).
192const OPEN_PR_LIST_LIMIT: &str = "100";
193
194/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
195/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
196fn open_pr_ttl() -> Duration {
197    crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
198}
199
200/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
201/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
202/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
203const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
204
205/// Default cadence for the GitHub rate-limit monitor (#1375).
206///
207/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
208/// spends nothing against any budget — so unlike the PR poller this cadence has no
209/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
210/// keeps `daemon status` current enough to catch a slow drain.
211const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
212
213/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
214/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
215fn rate_limit_poll_interval() -> Duration {
216    crate::daemon::server::duration_secs_from_env(
217        ENV_RATE_LIMIT_POLL_INTERVAL,
218        DEFAULT_RATE_LIMIT_POLL_INTERVAL,
219    )
220}
221
222/// A running background menu-refresh task and the token that stops it.
223struct RefreshTask {
224    /// Cancelled by `shutdown` to end the refresh loop.
225    token: CancellationToken,
226    /// The spawned loop, awaited on shutdown so it fully unwinds.
227    handle: JoinHandle<()>,
228}
229
230/// Whether this tick should spend a `gh` call.
231///
232/// The poller wakes far more often than it fetches — waking is a cached snapshot
233/// read, fetching is a subprocess and a network round trip. Two things justify the
234/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
235/// a push, invisible to the change-notify, so only looking finds it), or the
236/// **backoff elapsed** and it is simply time to look again.
237///
238/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
239/// worktree going away, a lease lapsing, or a TTL reap can never change any
240/// **surviving** badge, so `grew` is false and the poll skips — see
241/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
242///
243/// Pure so the policy is testable directly: from outside, the only evidence of it
244/// is *when* a subprocess runs, which a test cannot pin down without either flaking
245/// or passing for the wrong reason.
246fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
247    // `map_or(true, ..)` rather than `is_none_or`: the latter is stable only
248    // since 1.82 and this crate's MSRV is 1.80.
249    grew || since_last_fetch.map_or(true, |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    /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
502    /// choice survives a daemon restart. `None` disables persistence entirely —
503    /// the default from [`new`](Self::new), which keeps the bare service cheap
504    /// and I/O-free for unit tests; the daemon wires it via
505    /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
506    /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
507    /// briefly and never held across an `.await`.
508    polling_prefs_path: Mutex<Option<PathBuf>>,
509    /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
510    /// survive a daemon restart and the poller can skip its immediate re-poll when
511    /// they are still fresh. `None` disables persistence — the default from
512    /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
513    /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
514    /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
515    pr_cache_path: Mutex<Option<PathBuf>>,
516    /// The warm-start state restored from the persisted cache (#1389, fix 4),
517    /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
518    /// spawns. `None` on a cold start (no file, or persistence disabled), in which
519    /// case the poller does its normal first fetch.
520    pr_warm_start: Mutex<Option<PrWarmStart>>,
521    /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
522    /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
523    /// dedupe to one counted `gh` per repo instead of one per window. Behind an
524    /// `Arc` for parity with the other caches.
525    open_pr_cache: Arc<OpenPrCache>,
526    /// Where the windows the **last** `reposition` moved were sitting beforehand,
527    /// so `reposition-undo` can put them back (#1407).
528    ///
529    /// Exactly one level of undo, deliberately: the affordance exists because
530    /// repositioning is otherwise irreversible (the previous layout is simply
531    /// gone), and "undo the thing I just did" is the whole of what that needs. A
532    /// deeper stack would raise questions — what does undoing an older batch mean
533    /// once a newer one has moved the same window? — that a one-level store cannot
534    /// pose. Each successful `reposition` replaces it; each `reposition-undo`
535    /// consumes it, so an undo cannot be replayed.
536    ///
537    /// In-memory only, like every other piece of registry state: a daemon restart
538    /// drops it, and the user is simply left with the layout they have. Behind its
539    /// **own** `std::Mutex`, taken independently of the registry's (neither nests)
540    /// and never held across an `.await`.
541    reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
542    /// Serializes the `rebase` op's phase-2 execute across concurrent requests
543    /// (#1415) — the [`prune_lock`](Self::prune_lock) precedent, one op over.
544    ///
545    /// Within a batch the engine already rebases sequentially, on purpose: linked
546    /// worktrees share one object database, and `git rebase` writes refs and
547    /// packs into it. Two *concurrent requests* would defeat that, so the lock
548    /// restores it globally. It also means a second click cannot start a rebase of
549    /// a worktree the first is still mid-way through — the `operation`-in-progress
550    /// classifier only sees state that is already on disk.
551    ///
552    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
553    rebase_lock: tokio::sync::Mutex<()>,
554    /// Serializes the `push` op's phase-2 execute across concurrent requests
555    /// (#1443) — the [`rebase_lock`](Self::rebase_lock) twin.
556    ///
557    /// A successful push writes `refs/remotes/<remote>/<branch>` into the object
558    /// database and ref store that every linked worktree of the repository shares,
559    /// so concurrent batches would race on it. Taking the lock **before** the
560    /// re-plan (not merely around the execute) is what keeps the classification the
561    /// engine acts on from being invalidated by another batch's push mid-flight —
562    /// which for a lease is not benign: a plan taken outside the lock could still
563    /// say `would-force` against a tracking ref the other run has since advanced.
564    ///
565    /// Deliberately **separate** from `rebase_lock` rather than shared: a rebase and
566    /// a push touch different refs (`refs/heads/*` vs `refs/remotes/*`) and there is
567    /// no reason a push should wait behind an unrelated repository's rebase.
568    ///
569    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
570    push_lock: tokio::sync::Mutex<()>,
571}
572
573impl WorktreesService {
574    /// Creates the service with an empty registry. Cheap — no I/O and no task;
575    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
576    /// off-thread menu caching, while tests use the bare service (menu computed
577    /// inline on demand).
578    #[must_use]
579    pub fn new() -> Self {
580        let registry = Arc::new(WorktreesRegistry::new());
581        let pr_cache = Arc::new(PrStatusCache::new());
582        Self {
583            registry: registry.clone(),
584            menu_cache: Arc::new(Mutex::new(None)),
585            refresh: Mutex::new(None),
586            pr_cache: pr_cache.clone(),
587            poller: Mutex::new(None),
588            rate_limit_cache: Arc::new(RateLimitCache::new()),
589            rate_limit_poller: Mutex::new(None),
590            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
591            prune_lock: tokio::sync::Mutex::new(()),
592            polling_prefs_path: Mutex::new(None),
593            pr_cache_path: Mutex::new(None),
594            pr_warm_start: Mutex::new(None),
595            open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
596            reposition_undo: Mutex::new(Vec::new()),
597            rebase_lock: tokio::sync::Mutex::new(()),
598            push_lock: tokio::sync::Mutex::new(()),
599        }
600    }
601
602    /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
603    /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
604    /// back to it (#1376). Called once by the daemon at startup, before any window
605    /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
606    /// bump. Best-effort throughout: a missing file is the first-run default (no
607    /// repos enabled), and a corrupt/unreadable one is logged and treated as
608    /// empty rather than wedging the service — the user simply re-enables. The
609    /// path is stored regardless, so the next change rewrites a clean file.
610    pub fn load_polling_prefs(&self, path: PathBuf) {
611        match std::fs::read(&path) {
612            Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
613                Ok(prefs) => self
614                    .registry
615                    .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
616                Err(err) => tracing::warn!(
617                    "ignoring unreadable worktrees polling prefs at {}: {err:#}",
618                    path.display()
619                ),
620            },
621            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
622            Err(err) => tracing::warn!(
623                "could not read worktrees polling prefs at {}: {err:#}",
624                path.display()
625            ),
626        }
627        *self
628            .polling_prefs_path
629            .lock()
630            .unwrap_or_else(PoisonError::into_inner) = Some(path);
631    }
632
633    /// Writes the current enable set to the `0600` prefs file, if persistence is
634    /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
635    /// Best-effort: a write failure is logged at WARN and swallowed, since the
636    /// in-memory set is authoritative for the running daemon — the user's toggle
637    /// still took effect, it just would not survive a restart. A no-op (returns
638    /// early) in unit tests, which never configure a path.
639    fn persist_polling_prefs(&self) {
640        let Some(path) = self
641            .polling_prefs_path
642            .lock()
643            .unwrap_or_else(PoisonError::into_inner)
644            .clone()
645        else {
646            return;
647        };
648        let prefs = PollingPrefs {
649            enabled: self
650                .registry
651                .polling_snapshot()
652                .into_iter()
653                .map(|(repo, expires_at)| PollingLease { repo, expires_at })
654                .collect(),
655        };
656        if let Err(err) = write_polling_prefs(&path, &prefs) {
657            tracing::warn!(
658                "could not persist worktrees polling prefs to {}: {err:#}",
659                path.display()
660            );
661        }
662    }
663
664    /// Seeds the resolved PR-badge cache from the persisted `0600` file and
665    /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
666    /// once by the daemon at startup, before any window subscribes and before the
667    /// poller spawns, so restored badges render on the first tree snapshot and the
668    /// poller can skip its immediate re-poll for verdicts still fresh.
669    ///
670    /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
671    /// contract): a missing file is the cold-start default, and a corrupt/unreadable
672    /// one is logged and treated as empty — the poller simply re-resolves. The path
673    /// is stored regardless, so the next poll rewrites a clean file. Restores both
674    /// the badges (into [`pr_cache`](Self::pr_cache)) and the
675    /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
676    pub fn load_pr_cache(&self, path: PathBuf) {
677        match std::fs::read(&path) {
678            Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
679                Ok(prefs) => {
680                    self.pr_cache.seed(
681                        prefs
682                            .entries
683                            .into_iter()
684                            .map(|e| (e.target, e.resolution.into_resolution())),
685                    );
686                    // A warm start needs both a watch set to compare against and a
687                    // poll time to age it; without `polled_at` the file is too old a
688                    // shape to trust, so treat it as a cold start (badges still
689                    // render, the poller just re-polls immediately).
690                    if let Some(polled_at) = prefs.polled_at {
691                        let watched = prefs
692                            .watched
693                            .into_iter()
694                            .map(|w| PrWatch {
695                                target: w.target,
696                                upstream_sha: w.upstream_sha,
697                            })
698                            .collect();
699                        *self
700                            .pr_warm_start
701                            .lock()
702                            .unwrap_or_else(PoisonError::into_inner) =
703                            Some(PrWarmStart { watched, polled_at });
704                    }
705                }
706                // Bind the path so it is formatted whenever the branch runs — not
707                // only when a WARN subscriber is installed — so coverage sees it
708                // (the `let summary = …` pattern the rate-limit warn uses).
709                Err(err) => {
710                    let at = path.display();
711                    tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
712                }
713            },
714            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
715            Err(err) => {
716                let at = path.display();
717                tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
718            }
719        }
720        *self
721            .pr_cache_path
722            .lock()
723            .unwrap_or_else(PoisonError::into_inner) = Some(path);
724    }
725
726    /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
727    /// served from the shared TTL cache when fresh, else **one** counted `gh pr
728    /// list`. The `gh` runs on a blocking thread (never an async worker), routed
729    /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
730    /// point so the call is still counted exactly once — the constraint the whole
731    /// of #1389 preserves. The result is forwarded to the extension verbatim.
732    async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
733        // The `gh` binary is resolved once here (the env read is process-stable),
734        // then handed to the seam below — the poller's "bin as a param" pattern, so
735        // a test injects a stub without mutating the process environment (#1030).
736        self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
737            .await
738    }
739
740    /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
741    /// the cache against a stub without touching the environment.
742    async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
743        let key = format!("{owner}/{name}");
744        if let Some(prs) = self.open_pr_cache.fresh(&key) {
745            return Ok(prs);
746        }
747        let slug = key.clone();
748        let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
749            .await
750            .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
751        self.open_pr_cache.store(key, prs.clone());
752        Ok(prs)
753    }
754
755    /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
756    /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
757    /// for the built-in `status` op to read.
758    #[must_use]
759    pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
760        self.rate_limit_cache.clone()
761    }
762
763    /// Starts the background task that recomputes the tray menu snapshot every
764    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
765    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
766    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
767    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
768    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
769    /// keep computing the menu inline.
770    pub fn start_menu_refresh(&self) {
771        if tokio::runtime::Handle::try_current().is_err() {
772            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
773            return;
774        }
775        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
776        if guard.is_some() {
777            return;
778        }
779        let token = CancellationToken::new();
780        let loop_token = token.clone();
781        let registry = self.registry.clone();
782        let cache = self.menu_cache.clone();
783        let rate_limit_cache = self.rate_limit_cache.clone();
784        // Resolved once at spawn: the interval is process-stable env config, and
785        // re-reading it every loop would be wasted work.
786        let interval = menu_refresh_interval();
787        let handle = tokio::spawn(async move {
788            loop {
789                // Snapshot the registry (a cheap lock), then build the menu —
790                // which opens repos and parses git config — on a blocking thread,
791                // never on this async worker or the tray's main thread.
792                let entries = registry.list();
793                // The rate-limit reading (a cheap lock, `Copy`) prepends a status
794                // line; read it here and hand it to the blocking build (#1375).
795                let rate_limit = rate_limit_cache.get();
796                if let Ok(items) = tokio::task::spawn_blocking(move || {
797                    menu_items_for(&entries, rate_limit.as_ref())
798                })
799                .await
800                {
801                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
802                }
803                tokio::select! {
804                    () = loop_token.cancelled() => break,
805                    () = tokio::time::sleep(interval) => {}
806                }
807            }
808        });
809        *guard = Some(RefreshTask { token, handle });
810    }
811
812    /// Starts the background task that keeps PR check badges fresh (#1337).
813    ///
814    /// This is the half of the badge nothing else can do. Badges used to be
815    /// resolved extension-side on repo-expand, so they were recomputed only when a
816    /// repo node's children were rebuilt — and the streamed snapshot carries
817    /// worktree topology, not CI. While CI ran and no window opened or closed,
818    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
819    ///
820    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
821    /// (cost 1, independent of repo/worktree/window count), writes the cache the
822    /// tree snapshot reads, and bumps the registry's change-notify **only when a
823    /// verdict actually moved** — so the server's diff pushes to every open window
824    /// exactly when CI state changes, and never otherwise.
825    ///
826    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
827    /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
828    /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
829    /// terminal; it polls nothing at all while no window is registered.
830    ///
831    /// It spends a `gh` call only when the watch set **grows** — a target added or
832    /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
833    /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
834    /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
835    /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
836    /// 4), and the cadence is capped when the shared GitHub budget is strained
837    /// (#1389 fix 6).
838    ///
839    /// Idempotent, and a no-op outside a tokio runtime (mirroring
840    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
841    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
842    pub fn start_pr_poller(&self) {
843        // Resolved once at spawn: process-stable env config (the menu-refresh
844        // precedent), never re-read per poll.
845        self.start_pr_poller_with(
846            pr_poll_interval(),
847            pr_debounce_interval(),
848            crate::pr_status::resolve_gh_binary(),
849        );
850    }
851
852    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
853    /// debounce settle window, and `gh` binary, so tests drive the loop at
854    /// millisecond speed against a stub **without mutating the process
855    /// environment** — one global env var cannot serve two parallel tests pointing
856    /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
857    /// Snowflake heartbeat's "interval via config, not env" rule.
858    ///
859    /// Reads the rate-limit cache, the persistence path, and the warm-start state
860    /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
861    /// the original two-cadence seam.
862    fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
863        if tokio::runtime::Handle::try_current().is_err() {
864            tracing::debug!("no tokio runtime; worktrees PR poller not started");
865            return;
866        }
867        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
868        if guard.is_some() {
869            return;
870        }
871        let token = CancellationToken::new();
872        let loop_token = token.clone();
873        let registry = self.registry.clone();
874        let tree_cache = self.tree_cache.clone();
875        let pr_cache = self.pr_cache.clone();
876        // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
877        // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
878        // the loop once and must not be reused by a later restart of the poller.
879        let rate_limit_cache = self.rate_limit_cache.clone();
880        let pr_cache_path = self
881            .pr_cache_path
882            .lock()
883            .unwrap_or_else(PoisonError::into_inner)
884            .clone();
885        let warm_start = self
886            .pr_warm_start
887            .lock()
888            .unwrap_or_else(PoisonError::into_inner)
889            .take();
890        // Captured here, before the task's first sleep, so a window that registers
891        // while the loop is starting still wakes it rather than being missed.
892        let mut changes = self.registry.subscribe_changes();
893        let handle = tokio::spawn(async move {
894            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
895            // of the coalescing snapshot cache, no subprocess, no network. It only
896            // *asks GitHub* when there is reason to: the watch set grew (a target
897            // added, or an upstream pushed), or the backoff has elapsed.
898            //
899            // They have to be separate because the two things that should trigger a
900            // fetch arrive by different routes. A window opening bumps the registry's
901            // change-notify, but **a push does not** — nothing in the daemon is
902            // notified when you `git push`. The only way to notice is to look, so the
903            // loop looks often and cheaply, and pays only when something grew.
904            let mut backoff = base;
905            // Warm start (#1389, fix 4): resume what the previous daemon last
906            // resolved and when, so a restart within the backoff window skips the
907            // immediate re-poll for verdicts already restored into `pr_cache`.
908            // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
909            // reboot (monotonic epoch reset) or a future timestamp collapses to
910            // "never polled", which just re-polls — the safe direction.
911            let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
912                match warm_start {
913                    Some(ws) => {
914                        let elapsed = (Utc::now() - ws.polled_at)
915                            .to_std()
916                            .unwrap_or(Duration::ZERO);
917                        (Some(ws.watched), Instant::now().checked_sub(elapsed))
918                    }
919                    None => (None, None),
920                };
921            // When fresh work (a push or an added target) was last seen, so the
922            // pending cadence can escalate once it goes quiet (#1389, fix 5).
923            let mut moved_at: Option<Instant> = None;
924            'poll: loop {
925                // Wait first: at startup no window has registered yet, and the
926                // first snapshot would be empty anyway.
927                tokio::select! {
928                    () = loop_token.cancelled() => break,
929                    () = tokio::time::sleep(base) => {}
930                    // A window opened or closed — look now rather than at the next
931                    // tick, but debounce first.
932                    result = changes.changed() => {
933                        // Unreachable today: this task owns an `Arc` of the registry
934                        // that holds the sender, so it cannot be dropped while we are
935                        // here. Kept anyway because the alternative is worse — a
936                        // closed channel makes `changed()` return `Ready` forever, so
937                        // ignoring the error would spin this loop at full speed,
938                        // re-snapshotting and re-running `gh` every iteration.
939                        if result.is_err() {
940                            break;
941                        }
942                        // Debounce (#1389, fix 2): a VS Code restart unregisters then
943                        // re-registers its windows one-by-one over several seconds,
944                        // each bump waking us; a daemon restart re-registers the same
945                        // way. Wait for `debounce` of quiet before snapshotting so the
946                        // whole storm collapses to **one** fetch on the final watch
947                        // set. Bounded by an overall deadline so a steady drip of
948                        // changes cannot postpone the poll forever.
949                        let overall_deadline = Instant::now() + debounce.saturating_mul(4);
950                        loop {
951                            tokio::select! {
952                                () = loop_token.cancelled() => break 'poll,
953                                () = tokio::time::sleep(debounce) => break,
954                                r = changes.changed() => {
955                                    if r.is_err() {
956                                        break 'poll;
957                                    }
958                                    if Instant::now() >= overall_deadline {
959                                        break;
960                                    }
961                                }
962                            }
963                        }
964                    }
965                }
966                // Off the coalescing snapshot cache, so this reuses the tick's
967                // `build_tree` rather than walking git a second time.
968                let snapshot = tree_cache.snapshot().await;
969                let watch = pr_watch_from_snapshot(&snapshot);
970                if watch.is_empty() {
971                    // No windows, or nothing on GitHub: ask nothing, and forget any
972                    // backoff so the next tree starts fresh. But **keep** `watched`
973                    // (#1389, fix 4): a VS Code restart momentarily empties the watch
974                    // mid-storm, and nulling it here would make the re-registered set
975                    // look brand-new and re-fetch. A genuinely gone tree simply has
976                    // nothing to compare against on the next non-empty tick.
977                    backoff = base;
978                    last_poll = None;
979                    moved_at = None;
980                    continue;
981                }
982                // Did the watched set **grow** (an addition, or an upstream pushed)?
983                // A pure removal is not a reason to fetch (#1389, fix 1); a local
984                // commit is not either (#1389, fix 3, `PrWatch` carries no head).
985                let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
986                // Prune verdicts for targets that vanished, so a closed worktree's
987                // badge does not linger in the cache (#1389, fix 1) — local, no
988                // network, and correct whether or not this tick goes on to fetch.
989                let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
990                pr_cache.retain_targets(&keep);
991                // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
992                // choke point, so throttling here is the one place a machine-wide cap
993                // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
994                // immediate `grew`, so no runaway in this class can drain the shared
995                // budget — structurally, not by convention.
996                let rate_limit = rate_limit_cache.get();
997                let over_budget = rate_limit.is_some_and(|s| s.over_warn());
998                let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
999                let trigger = grew && !over_budget;
1000                if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1001                    // Not fetching this tick. Advance `watched` only for a pure shrink
1002                    // or a quiet identical tick — an addition/push (`grew`) must stay
1003                    // unresolved so it is still fetched once the cadence or budget
1004                    // allows, rather than being silently consumed here.
1005                    if !grew {
1006                        watched = Some(watch);
1007                    }
1008                    continue;
1009                }
1010                if grew {
1011                    // Fresh work: watch it closely and restart the escalation clock
1012                    // rather than serving out a backoff earned while it was quiet.
1013                    backoff = base;
1014                    moved_at = Some(Instant::now());
1015                }
1016                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1017                // `gh` is a blocking subprocess: never on an async worker. A join
1018                // failure (the task panicked, or the runtime is going down) folds
1019                // into the same error channel as a `gh` failure — both mean "no
1020                // badges this round", and neither deserves its own handling.
1021                let bin = gh_bin.clone();
1022                let resolved = tokio::task::spawn_blocking(move || {
1023                    crate::pr_status::resolve_with_budget(&bin, &targets)
1024                })
1025                .await
1026                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1027                // Best-effort decoration: a missing/unauthenticated `gh`, a network
1028                // blip, or a rate limit must never sink the tree. A failed poll
1029                // leaves the last good resolutions in place — badges *and* explicit
1030                // negatives, and it mints no new negatives (#1370) — rather than
1031                // blanking every row, and is not "pending", so it backs off rather
1032                // than hammers.
1033                let (pending, resolved_ok) = match resolved {
1034                    Ok((resolutions, budget)) => {
1035                        // Fold the free budget reading this poll carried into the
1036                        // shared cache (#1389, fix 8): the graphql figure stays fresh
1037                        // whenever polling is active, which lets the standalone
1038                        // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
1039                        // reveals the real per-call point price.
1040                        if let Some(b) = budget {
1041                            tracing::debug!(
1042                                "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1043                                b.cost,
1044                                b.used,
1045                                b.limit,
1046                                b.remaining
1047                            );
1048                            rate_limit_cache.observe_graphql(RateLimitResource::new(
1049                                b.used,
1050                                b.limit,
1051                                b.remaining,
1052                                b.reset,
1053                            ));
1054                        }
1055                        // Bump only on a real change, or the server's diff-and-drop
1056                        // is defeated and every window re-renders on every poll.
1057                        if pr_cache.replace(resolutions) {
1058                            registry.bump();
1059                        }
1060                        (pr_cache.any_pending(), true)
1061                    }
1062                    Err(err) => {
1063                        tracing::debug!("PR badge poll failed: {err:#}");
1064                        (false, false)
1065                    }
1066                };
1067                last_poll = Some(Instant::now());
1068                // Record what this verdict was about, so the *next* tick can tell a
1069                // genuine change from a quiet tree.
1070                watched = Some(watch);
1071                let since_moved = moved_at.map(|at| at.elapsed());
1072                backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1073                // Persist the fresh verdicts (#1389, fix 4) so the next restart
1074                // serves them and can skip its immediate re-poll. Only on a real
1075                // resolution — a failed poll must not advance the persisted poll time
1076                // past the last *good* one. Best-effort; a write failure costs at
1077                // most one extra poll after the next restart.
1078                if resolved_ok {
1079                    if let Some(path) = &pr_cache_path {
1080                        persist_pr_cache(
1081                            path,
1082                            &pr_cache,
1083                            watched.as_deref().unwrap_or(&[]),
1084                            Utc::now(),
1085                        );
1086                    }
1087                }
1088            }
1089        });
1090        *guard = Some(PollerTask { token, handle });
1091    }
1092
1093    /// Starts the background task that keeps the GitHub API rate-limit reading
1094    /// fresh (#1375).
1095    ///
1096    /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1097    /// budget as every other tool sharing the user's token; when that drains, `gh`
1098    /// rate-limits machine-wide with no warning until commands start failing. This
1099    /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1100    /// project verified) as **exempt**, spending nothing against any budget — so
1101    /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1102    /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1103    ///
1104    /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1105    /// backoff and no window-gating, because the endpoint is free and a current
1106    /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1107    /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1108    /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1109    /// bare service that never spawns `gh`.
1110    pub fn start_rate_limit_poller(&self) {
1111        // Both resolved once at spawn: process-stable env config, never re-read per
1112        // poll (the PR-poller precedent).
1113        self.start_rate_limit_poller_with(
1114            rate_limit_poll_interval(),
1115            crate::pr_status::resolve_gh_binary(),
1116        );
1117    }
1118
1119    /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1120    /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1121    /// against a stub **without mutating the process environment** (the
1122    /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1123    fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1124        if tokio::runtime::Handle::try_current().is_err() {
1125            tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1126            return;
1127        }
1128        let mut guard = self
1129            .rate_limit_poller
1130            .lock()
1131            .unwrap_or_else(PoisonError::into_inner);
1132        if guard.is_some() {
1133            return;
1134        }
1135        let token = CancellationToken::new();
1136        let loop_token = token.clone();
1137        let cache = self.rate_limit_cache.clone();
1138        let registry = self.registry.clone();
1139        let handle = tokio::spawn(async move {
1140            // Remembers the previous reading so a WARN fires only on the *rising*
1141            // edge across the threshold, not every poll while usage stays high.
1142            let mut prev: Option<RateLimitSnapshot> = None;
1143            loop {
1144                // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1145                // no window registered and no polling lease active — has nothing to
1146                // watch, so it spends no `/rate_limit` subprocess and lets the last
1147                // reading stand. The read is free against the budget, but the
1148                // wakeups are not; and while polling *is* active the graphql figure
1149                // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1150                // standalone poll is only topping up `core`/`search`.
1151                if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1152                    // Poll first, so `status` has a reading soon after a window
1153                    // appears rather than one interval later. `gh` is a blocking
1154                    // subprocess: never on an async worker. A join failure folds into
1155                    // the same channel as a `gh` failure — both mean "no fresh
1156                    // reading this round".
1157                    let bin = gh_bin.clone();
1158                    let resolved =
1159                        tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1160                            .await
1161                            .unwrap_or_else(|err| {
1162                                Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1163                            });
1164                    match resolved {
1165                        Ok(snap) => {
1166                            if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1167                                // Bound to a local (rather than inlined into the
1168                                // macro) so it is computed whenever the branch is
1169                                // taken, not only when a WARN-level subscriber is
1170                                // installed — `tracing` skips evaluating macro args
1171                                // otherwise.
1172                                let summary = snap.summary_line();
1173                                tracing::warn!(
1174                                    "GitHub API rate limit high: {summary} (querying \
1175                                     /rate_limit is free; the daemon's gh usage is not)"
1176                                );
1177                            }
1178                            // Update the cache only; deliberately no `registry.bump()`
1179                            // — the rate limit is not tree topology, and bumping would
1180                            // re-push an unchanged tree to every window. The tray
1181                            // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1182                            cache.replace(snap);
1183                            prev = Some(snap);
1184                        }
1185                        // Best-effort decoration: a missing/unauthenticated `gh` or a
1186                        // network blip leaves the last good reading in place rather
1187                        // than blanking the line, and never affects the budget (the
1188                        // read is free).
1189                        Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1190                    }
1191                }
1192                tokio::select! {
1193                    () = loop_token.cancelled() => break,
1194                    () = tokio::time::sleep(interval) => {}
1195                }
1196            }
1197        });
1198        *guard = Some(PollerTask { token, handle });
1199    }
1200
1201    /// Handles the `close` op: close a worktree's window and, for a **linked**
1202    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1203    ///
1204    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1205    ///   [`git_safety`] check returning the risks of deleting, so the extension can
1206    ///   show a modal confirm only when something would actually be lost.
1207    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1208    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1209    ///   worktree. The main working tree is refused defensively.
1210    ///
1211    /// Cross-window signalling (another window has the target open) is a
1212    /// fast-follow: this core handles the **no-window** and **self-close**
1213    /// (`requester_key == target_key`) cases, and errors clearly when another
1214    /// window owns the target so the destructive path is never taken blind.
1215    async fn close(&self, req: CloseRequest) -> Result<Value> {
1216        // Which live windows currently have the target open. The canonical-path
1217        // compare is disk I/O, so run it (with the safety check below) on a
1218        // blocking thread, never under the registry lock or on the async worker.
1219        let entries = self.registry.list();
1220        let scan_path = req.path.clone();
1221        let open_windows =
1222            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1223                .await
1224                .unwrap_or_default();
1225        let open = !open_windows.is_empty();
1226        let window_key = open_windows.first().map(|(k, _)| k.clone());
1227        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1228
1229        // Phase 1: the safety check runs only for a delete request awaiting
1230        // confirmation. A "Close Window" (remove:false) never inspects git and
1231        // has nothing to confirm, so it skips straight to execute.
1232        if req.remove && !req.confirmed {
1233            let path = req.path.clone();
1234            let git = tokio::task::spawn_blocking(move || git_safety(&path))
1235                .await
1236                .map_err(|e| anyhow!("safety check task panicked: {e}"))
1237                .and_then(|inner| inner)
1238                .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1239            // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1240            // (#1364): the target, the owning window key (if any), whether a
1241            // window has it open, and the deletability verdict — with the
1242            // blocking risk kinds that force a confirm dialog, so a later "why did
1243            // the close prompt/refuse?" is answerable from the log alone.
1244            log_safety_check(&req.path, window_key.as_deref(), &git, open);
1245            return Ok(serde_json::to_value(SafetyReport {
1246                removable: git.removable,
1247                is_main: git.is_main,
1248                open,
1249                window_key,
1250                window_folder_count,
1251                risks: git.risks,
1252                info: git.info,
1253            })
1254            .unwrap_or_else(|_| json!({})));
1255        }
1256
1257        // Phase 2: execute. Signal every owning window *other than the
1258        // requester* (which closes itself on our `ok:true` reply, avoiding the
1259        // ext-host-dies-mid-op race) and wait for each to unregister before
1260        // touching the worktree. The directive reaches a cross-window target via
1261        // its heartbeat reply — the only channel the daemon has to a window it
1262        // can reply to but never call.
1263        let others: Vec<String> = open_windows
1264            .iter()
1265            .map(|(k, _)| k.clone())
1266            .filter(|k| req.requester_key.as_deref() != Some(k))
1267            .collect();
1268        // A self-close is the requester closing a window it owns: it never rides
1269        // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1270        // (#1364) so the execute's routing decision is auditable before the wait,
1271        // even if that wait then hangs or times out.
1272        let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1273        log_executing(
1274            &req.path,
1275            req.requester_key.as_deref(),
1276            req.remove,
1277            self_close,
1278            others.len(),
1279        );
1280        for key in &others {
1281            self.registry.mark_close_pending(key);
1282        }
1283        if !others.is_empty() {
1284            if let Err(err) = await_windows_closed(
1285                &self.registry,
1286                &req.path,
1287                req.requester_key.as_deref(),
1288                CLOSE_WAIT_TIMEOUT,
1289                CLOSE_WAIT_POLL,
1290            )
1291            .await
1292            {
1293                log_close_abort(&req.path, &err);
1294                return Err(err);
1295            }
1296        }
1297
1298        if req.remove {
1299            let path = req.path.clone();
1300            // The live window set, so a working-tree-gone-but-admin-present orphan
1301            // can find its owning main repo to prune (#1403); unused on the common
1302            // path where the checkout still exists.
1303            let entries = self.registry.list();
1304            // Taken *after* the wait above, so concurrent executes still overlap
1305            // their heartbeat waits (#1359) and only the prune itself serializes.
1306            // Load-bearing placement, not incidental: hoisting this above
1307            // `await_windows_closed` would restack the waits and undo the whole
1308            // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1309            let _guard = self.prune_lock.lock().await;
1310            let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1311                .await
1312                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1313                .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1314            // The audit line + Result→reply mapping lives in a sync helper so the
1315            // destructive outcome is unit-testable off the runtime (#1364).
1316            log_and_map_removal(&req.path, removed)
1317        } else {
1318            // "Close Window" with no owning window is a no-op success; a
1319            // self-close replies and the extension closes its own window.
1320            log_window_closed(&req.path);
1321            Ok(json!({ "closed": true }))
1322        }
1323    }
1324
1325    /// Handles the `reload` op (#1417): signal each target window to reload
1326    /// itself, returning `{ requested, signalled, unknown }`.
1327    ///
1328    /// Synchronous, and deliberately so — the whole op is a set insert per key.
1329    /// It marks a directive on each *currently registered* target and returns;
1330    /// the window acts on it on its next `heartbeat`, up to the ~10s cadence
1331    /// later. Unlike [`close`](Self::close) it never waits, because a reload has
1332    /// no completion the daemon can observe (the window re-registers under the
1333    /// same key), which is why the reply says `signalled`, never `reloaded`.
1334    ///
1335    /// A key with no live window is reported in `unknown` rather than erroring:
1336    /// the batch is a sweep, and a window closing between the client rendering
1337    /// its list and sending the op is routine, not a failure. `list()` reaps
1338    /// stale entries on read, so a window that died without unregistering is
1339    /// correctly unknown here.
1340    fn reload(&self, req: ReloadRequest) -> Value {
1341        let live: HashSet<String> = self
1342            .registry
1343            .list()
1344            .into_iter()
1345            .map(|entry| entry.key)
1346            .collect();
1347
1348        let mut seen = HashSet::new();
1349        let mut signalled = 0usize;
1350        let mut unknown = Vec::new();
1351        for key in &req.target_keys {
1352            // A client repeating a key asks for one reload, not two.
1353            if !seen.insert(key.as_str()) {
1354                continue;
1355            }
1356            if live.contains(key) {
1357                self.registry.mark_reload_pending(key);
1358                signalled += 1;
1359            } else {
1360                unknown.push(key.clone());
1361            }
1362        }
1363
1364        log_reload(seen.len(), signalled, &unknown);
1365        json!({
1366            "requested": seen.len(),
1367            "signalled": signalled,
1368            "unknown": unknown,
1369        })
1370    }
1371
1372    /// Handles the `merge-queue` op (#1401): batch-enqueue the eligible worktrees'
1373    /// PRs into the GitHub merge queue. Two-phase, keyed off `confirmed`:
1374    ///
1375    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run the
1376    ///   side-effect-free eligibility evaluation ([`evaluate_batch`]) and return an
1377    ///   [`EligibilityReport`]: the enqueue-eligible worktrees and the skipped ones
1378    ///   (each with a machine `kind` + human `detail`).
1379    /// - **Phase 2** (`confirmed:true`) — **re-run** the same evaluation (never
1380    ///   trust a phase-1 result the client sent, exactly as `close` re-validates on
1381    ///   execute), then enqueue each still-eligible PR. A per-PR rejection lands in
1382    ///   `failed[]`; the batch never fails as a whole.
1383    ///
1384    /// All git and `gh` I/O runs on a blocking thread — never the async worker and
1385    /// never under the registry lock. No lock is taken: each enqueue mutates a
1386    /// distinct remote PR, not a shared local resource.
1387    async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1388        // Resolved once here (the env read is process-stable), then handed to the
1389        // seam below — the `open_prs`/`open_prs_with` "bin as a param" pattern, so a
1390        // test drives the eligibility + enqueue paths against a fake `gh` without
1391        // touching the environment (#1030).
1392        self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1393            .await
1394    }
1395
1396    /// [`merge_queue`](Self::merge_queue) with an explicit `gh` binary, so a test
1397    /// exercises the phase-1 network resolve and the phase-2 enqueue against a stub.
1398    async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1399        // Report-only unless explicitly confirmed; an explicit `check` request
1400        // always reports and never enqueues.
1401        let report_only = req.check || !req.confirmed;
1402
1403        let eval_bin = bin.clone();
1404        let eval_paths = req.paths.clone();
1405        let (eligible, skipped) =
1406            tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1407                .await
1408                .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1409                .and_then(|inner| inner)?;
1410
1411        if report_only {
1412            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1413            log_merge_check(&req, eligible.len(), skipped.len());
1414            let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1415            return Ok(
1416                serde_json::to_value(EligibilityReport { eligible, skipped })
1417                    .unwrap_or_else(|_| json!({})),
1418            );
1419        }
1420
1421        // Phase 2: enqueue the freshly re-validated eligible set, sequentially.
1422        let enqueue_bin = bin.clone();
1423        let (queued, failed) =
1424            tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1425                .await
1426                .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1427        log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1428        Ok(serde_json::to_value(EnqueueResult {
1429            queued,
1430            skipped,
1431            failed,
1432        })
1433        .unwrap_or_else(|_| json!({})))
1434    }
1435
1436    /// Handles the `rebase` op (#1415): batch-rebase the selected worktrees onto
1437    /// their repository's remote default branch, fetching it **once per
1438    /// repository**. Two-phase, keyed off `confirmed`, exactly like `merge-queue`:
1439    ///
1440    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1441    ///   [`worktree_rebase::plan`], which fetches once per repo and classifies
1442    ///   every selected worktree. This *is* the "only rebase if it makes sense
1443    ///   from the current git state" gate: the classifier skips a detached HEAD, a
1444    ///   dirty tree, an operation already in progress, a non-worktree path, an
1445    ///   unresolvable onto ref, and anything already up to date — the main working
1446    ///   tree is a valid target like any other (ADR-0060). Side-effect-free apart
1447    ///   from the fetch, which only advances a remote-tracking ref.
1448    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch** (never trust a
1449    ///   phase-1 result the client sent back, as `close` and `merge-queue` do),
1450    ///   then execute. A worktree that went dirty between the phases is skipped
1451    ///   rather than rebased.
1452    ///
1453    /// **Why the daemon may do this at all** (ADR-0059): ADR-0055 confined the
1454    /// rebase to the CLI on the premise that the daemon could not authenticate a
1455    /// fetch. It can — launchd exports `SSH_AUTH_SOCK` into the per-user session,
1456    /// so the daemon inherits the user's `ssh-agent`. The real gap was the minimal
1457    /// `PATH`, closed by [`crate::git::resolve_git_binary`].
1458    ///
1459    /// All git I/O runs on a blocking thread, never the async worker and never
1460    /// under the registry lock.
1461    async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1462        // Resolved once here (the probe is process-stable), then handed to the
1463        // seam below — the `merge_queue_with` "bin as a param" pattern, so a test
1464        // drives both phases against a stub without touching the environment.
1465        self.rebase_with(req, crate::git::resolve_git_binary())
1466            .await
1467    }
1468
1469    /// [`rebase`](Self::rebase) with an explicit `git` binary, so a test exercises
1470    /// the plan and execute paths against a stub.
1471    async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1472        if req.paths.is_empty() {
1473            bail!("`rebase` requires at least one path");
1474        }
1475        // Report-only unless explicitly confirmed; an explicit `check` request
1476        // always reports and never rebases.
1477        let report_only = req.check || !req.confirmed;
1478        let opts = req.options(git_bin);
1479        let selection = Selection::Paths(req.paths.clone());
1480
1481        if report_only {
1482            // Phase 1: fetch once per repo and classify, rebase nothing. No lock —
1483            // it mutates no worktree, and a plan is allowed to race an execute.
1484            let plan = plan_rebase(&selection, &opts).await?;
1485            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1486            log_rebase_check(&req, &plan);
1487            return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1488        }
1489
1490        // Phase 2: re-plan and execute, serialized against other executes —
1491        // linked worktrees share one object database (see `rebase_lock`).
1492        //
1493        // The lock is taken **before** the re-plan, not merely around the execute,
1494        // and that ordering is load-bearing. A plan taken outside it can be
1495        // invalidated by a concurrent execute before this one gets its turn — and
1496        // acting on a stale plan is not benign: if the other run left a worktree
1497        // mid-rebase, a stale `WouldRebase` here would run `git rebase` against a
1498        // repository that is already mid-rebase and (without `keep_conflicts`)
1499        // `--abort` it, destroying exactly the conflict resolution the other run
1500        // was preserving. Planning under the lock means the classifier sees that
1501        // worktree's real state and skips it as `operation-in-progress`.
1502        let _guard = self.rebase_lock.lock().await;
1503        let plan = plan_rebase(&selection, &opts).await?;
1504        // The worktrees actually about to be rewritten, canonicalized here (disk
1505        // I/O belongs in the adapter, not the registry) so they match the tree
1506        // snapshot's own keys.
1507        let pending: Vec<PathBuf> = plan
1508            .worktrees
1509            .iter()
1510            .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1511            .map(|w| canonical(&w.path))
1512            .collect();
1513        self.registry.mark_rebasing(&pending);
1514        let fetches = plan.fetches.clone();
1515        let exec_opts = opts.clone();
1516        let outcomes =
1517            tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1518        // Cleared on **every** exit, including a panicked task, so a failed rebase
1519        // can never leave a permanent spinner on a tree row.
1520        self.registry.clear_rebasing(&pending);
1521        let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1522
1523        log_rebase_execute(&req, &outcomes);
1524        Ok(rebase_reply(&fetches, &outcomes))
1525    }
1526
1527    /// Handles the `push` op (#1443): publish the selected worktrees' branches to
1528    /// their upstreams, force-pushing **with a lease** where a rebase rewrote
1529    /// history. The complement of [`rebase`](Self::rebase), and two-phase in
1530    /// exactly the same way:
1531    ///
1532    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1533    ///   [`worktree_push::plan`], which classifies every selected worktree against
1534    ///   its upstream. This *is* the "does this make sense?" gate: it skips a
1535    ///   detached HEAD (which also covers a worktree mid-rebase), a non-worktree
1536    ///   path, a branch with nowhere to publish, and a force-push of the
1537    ///   repository's remote default branch — while a dirty tree is deliberately
1538    ///   **not** a skip, since a push publishes commits rather than the working
1539    ///   tree. Unlike `rebase`'s phase 1 this is not merely side-effect-*light*: it
1540    ///   contacts no remote at all (ADR-0061).
1541    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch**, then execute. A
1542    ///   phase-1 result the client sends back is never trusted, as `close`,
1543    ///   `merge-queue` and `rebase` all re-validate. A branch that moved between
1544    ///   the phases is re-classified, not pushed on stale information.
1545    ///
1546    /// **Why the daemon may do this at all** (ADR-0061): this is the first op to
1547    /// write to a remote using the user's ambient *git* credentials — `merge-queue`
1548    /// mutates the remote through `gh` (ADR-0056) and `rebase` only fetches
1549    /// (ADR-0059). It stays same-user-bounded behind the `0600` socket, publishes
1550    /// only branches the client names, and can never overwrite work it has not
1551    /// seen, because the lease is enforced by `git` itself.
1552    ///
1553    /// All git I/O runs on a blocking thread, never the async worker and never
1554    /// under the registry lock.
1555    async fn push(&self, req: PushRequest) -> Result<Value> {
1556        // Resolved once here (the probe is process-stable), then handed to the
1557        // seam below — the `rebase_with` "bin as a param" pattern.
1558        self.push_with(req, crate::git::resolve_git_binary()).await
1559    }
1560
1561    /// [`push`](Self::push) with an explicit `git` binary, so a test exercises the
1562    /// plan and execute paths against a stub.
1563    async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1564        if req.paths.is_empty() {
1565            bail!("`push` requires at least one path");
1566        }
1567        // Report-only unless explicitly confirmed; an explicit `check` request
1568        // always reports and never pushes.
1569        let report_only = req.check || !req.confirmed;
1570        let selection = Selection::Paths(req.paths.clone());
1571
1572        if report_only {
1573            // Phase 1: classify only. No lock and no network — planning reads the
1574            // local remote-tracking refs, which is exactly what the lease is
1575            // checked against.
1576            let plan = plan_push(&selection).await?;
1577            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1578            log_push_check(&req, &plan);
1579            return Ok(push_reply(&plan.worktrees));
1580        }
1581
1582        // Phase 2: re-plan and execute, serialized against other executes — a push
1583        // writes into the ref store linked worktrees share (see `push_lock`). The
1584        // lock is taken **before** the re-plan for the same reason `rebase` takes
1585        // its own that way: a plan plus a lease are only meaningful together, and a
1586        // plan taken outside the lock can be invalidated before its turn comes.
1587        let _guard = self.push_lock.lock().await;
1588        let plan = plan_push(&selection).await?;
1589        // The worktrees actually about to be published, canonicalized here (disk
1590        // I/O belongs in the adapter, not the registry) so they match the tree
1591        // snapshot's own keys.
1592        let pending: Vec<PathBuf> = plan
1593            .worktrees
1594            .iter()
1595            .filter(|w| w.result.is_pending())
1596            .map(|w| canonical(&w.path))
1597            .collect();
1598        self.registry.mark_pushing(&pending);
1599        let opts = worktree_push::PushOptions {
1600            git_bin: Some(git_bin),
1601        };
1602        let outcomes =
1603            tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1604        // Cleared on **every** exit, including a panicked task. A push writes no
1605        // on-disk state, so this set is the *whole* cue — a mark left behind would
1606        // be a permanent spinner nothing else could correct.
1607        self.registry.clear_pushing(&pending);
1608        let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1609
1610        log_push_execute(&req, &outcomes);
1611        Ok(push_reply(&outcomes))
1612    }
1613
1614    /// Handles the `reposition` op (#1407): move and resize each target worktree's
1615    /// **already-open** VS Code window to match the invoking window's geometry.
1616    ///
1617    /// The invoking window is the reference: it supplies the frame and is never
1618    /// itself moved. A target with no open window, no resolvable OS window, or an
1619    /// ambiguous name is reported rather than guessed at. Z-order is untouched —
1620    /// see [`geometry::ax`] for why the Accessibility API gives that for free, and
1621    /// why this must **not** reuse [`focus_window`], which deliberately raises.
1622    ///
1623    /// `check: true` is a dry run: everything resolves exactly as it would for a
1624    /// real run, but nothing is written. That is the whole diagnostic surface for
1625    /// title matching (`worktrees reposition --dry-run`).
1626    ///
1627    /// Not two-phase like `close`/`merge-queue`: nothing durable is created,
1628    /// modified, or destroyed, so a confirmation on a routine layout command would
1629    /// cost more than it protects. Reversibility is provided instead, by
1630    /// [`reposition_undo`](Self::reposition_undo).
1631    async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1632        self.reposition_with(req, geometry::ax::AxBackend::new)
1633            .await
1634    }
1635
1636    /// [`reposition`](Self::reposition) with the platform backend injected as a
1637    /// **factory**, so a test drives the whole op — key resolution, the undo
1638    /// store, the reply shape — against a fake with no `unsafe` and no windows.
1639    ///
1640    /// A factory rather than the backend itself because the real backend holds
1641    /// CoreFoundation references and so is neither `Send` nor `Sync`: it has to be
1642    /// built *inside* the blocking closure. The `merge_queue_with` "seam as a
1643    /// parameter" pattern, one level of indirection over.
1644    async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1645    where
1646        B: geometry::WindowBackend,
1647        F: FnOnce() -> B + Send + 'static,
1648    {
1649        if req.reference_key.trim().is_empty() {
1650            bail!("`reposition` requires a non-empty `reference_key`");
1651        }
1652        // Resolve keys against the registry *here*, so all AX work below deals in
1653        // plain data and the registry lock is never held across the blocking join.
1654        let entries = self.registry.list();
1655        let reference = registered_window(&entries, &req.reference_key);
1656        if !reference.live {
1657            // Unlike a target, an unresolvable *reference* is a hard error: there
1658            // is no geometry to copy, so the request cannot mean anything.
1659            bail!(
1660                "no open window with key {} (it may have closed)",
1661                req.reference_key
1662            );
1663        }
1664        // A target key with no live window is a reportable per-target outcome, not
1665        // a failure of the batch — the tree row may simply be a tick stale.
1666        let targets: Vec<geometry::RegisteredWindow> = req
1667            .target_keys
1668            .iter()
1669            .map(|key| registered_window(&entries, key))
1670            .collect();
1671
1672        let check = req.check;
1673        let mut report = tokio::task::spawn_blocking(move || {
1674            // The backend lives for exactly one op, so its enumeration cache can
1675            // never serve a window that has since moved or closed.
1676            let backend = make_backend();
1677            geometry::reposition(&backend, &reference, &targets, check)
1678        })
1679        .await
1680        .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1681
1682        // Taken out of the report rather than cloned: nothing downstream reads it,
1683        // and the store is its only owner. A dry run leaves the previous batch's
1684        // record intact — it changed nothing, so there is neither something new to
1685        // undo nor something stale to discard.
1686        let undo = std::mem::take(&mut report.undo);
1687        let undoable = !check && !undo.is_empty();
1688        if undoable {
1689            *self
1690                .reposition_undo
1691                .lock()
1692                .unwrap_or_else(PoisonError::into_inner) = undo;
1693        }
1694        log_reposition(&req, &report);
1695        Ok(reposition_reply(&report, undoable))
1696    }
1697
1698    /// Handles the `reposition-undo` op (#1407): put the windows the last
1699    /// `reposition` moved back where they were.
1700    ///
1701    /// Consumes the stored batch, so an undo cannot be replayed onto windows the
1702    /// user has since arranged by hand. Every window is re-resolved from scratch —
1703    /// one that has closed, been renamed, or gone fullscreen in the meantime is
1704    /// reported, not forced.
1705    async fn reposition_undo(&self) -> Result<Value> {
1706        self.reposition_undo_with(geometry::ax::AxBackend::new)
1707            .await
1708    }
1709
1710    /// [`reposition_undo`](Self::reposition_undo) with the backend factory
1711    /// injected, for the same reasons as
1712    /// [`reposition_with`](Self::reposition_with).
1713    async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1714    where
1715        B: geometry::WindowBackend,
1716        F: FnOnce() -> B + Send + 'static,
1717    {
1718        let stored = std::mem::take(
1719            &mut *self
1720                .reposition_undo
1721                .lock()
1722                .unwrap_or_else(PoisonError::into_inner),
1723        );
1724        if stored.is_empty() {
1725            return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1726        }
1727        let entries = self.registry.list();
1728        let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1729            .into_iter()
1730            .map(|(key, frame)| (registered_window(&entries, &key), frame))
1731            .collect();
1732
1733        let report = tokio::task::spawn_blocking(move || {
1734            let backend = make_backend();
1735            geometry::restore(&backend, &restore)
1736        })
1737        .await
1738        .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1739        log_reposition_undo(&report);
1740        Ok(reposition_reply(&report, false))
1741    }
1742}
1743
1744impl Default for WorktreesService {
1745    fn default() -> Self {
1746        Self::new()
1747    }
1748}
1749
1750#[async_trait]
1751impl DaemonService for WorktreesService {
1752    fn name(&self) -> &'static str {
1753        SERVICE_NAME
1754    }
1755
1756    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1757        match op {
1758            "register" => {
1759                let req: RegisterRequest =
1760                    serde_json::from_value(payload).context("invalid `register` payload")?;
1761                if req.key.trim().is_empty() {
1762                    bail!("`register` requires a non-empty `key`");
1763                }
1764                self.registry.register(req);
1765                Ok(json!({ "ok": true }))
1766            }
1767            "heartbeat" => {
1768                let key = require_str(&payload, "key", "heartbeat")?;
1769                let known = self.registry.heartbeat(key);
1770                // A pending close directive (#1277) rides the reply as an
1771                // additive `close` field, taken-and-cleared here so it fires
1772                // exactly once. Omitted when false to keep older windows — which
1773                // read only `known` — byte-identical on the wire.
1774                let mut reply = json!({ "known": known });
1775                if self.registry.take_close_pending(key) {
1776                    reply["close"] = Value::Bool(true);
1777                }
1778                // A pending reload directive (#1417) rides the same reply, on
1779                // the same terms. Deliberately an independent `if`, not an
1780                // `else`: each field then means exactly "this directive was
1781                // pending", and each is taken exactly once regardless of the
1782                // other. The companion resolves a both-set collision by
1783                // checking `close` first, since closing subsumes reloading.
1784                if self.registry.take_reload_pending(key) {
1785                    reply["reload"] = Value::Bool(true);
1786                }
1787                Ok(reply)
1788            }
1789            "unregister" => {
1790                let key = require_str(&payload, "key", "unregister")?;
1791                Ok(json!({ "removed": self.registry.unregister(key) }))
1792            }
1793            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1794            "tree" => {
1795                // The same `{ repos, show_closed }` snapshot the `subscribe`
1796                // stream pushes, so a one-shot `tree` fetch and the live stream
1797                // agree byte-for-byte (the git enumeration runs off-lock on a
1798                // blocking thread inside the helper). Computed fresh here — the
1799                // `tree` op is a rare manual refresh, deliberately bypassing the
1800                // stream's coalescing cache so it never returns a stale view
1801                // (#1303).
1802                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1803            }
1804            "ahead-behind" => {
1805                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1806                // snapshot no longer carries ahead/behind — the dominant
1807                // per-worktree cost when computed eagerly every tick — so a client
1808                // (the extension on expand, `worktrees tree`) asks for it here only
1809                // for the worktrees it is about to show. Batched by path, one op per
1810                // repo expand; the git walks run on a blocking thread.
1811                let paths = payload
1812                    .get("paths")
1813                    .and_then(Value::as_array)
1814                    .map(|arr| {
1815                        arr.iter()
1816                            .filter_map(Value::as_str)
1817                            .map(PathBuf::from)
1818                            .collect::<Vec<_>>()
1819                    })
1820                    .unwrap_or_default();
1821                Ok(json!({ "results": ahead_behind_results(paths).await }))
1822            }
1823            "set-show-closed" => {
1824                // The daemon-backed show/hide-closed toggle (#1301). Setting it
1825                // bumps the change-notify, so every subscribed window re-pushes a
1826                // snapshot carrying the new `show_closed` — reliable cross-window
1827                // sync `context.globalState` could not do.
1828                let show_closed = payload
1829                    .get("show_closed")
1830                    .and_then(Value::as_bool)
1831                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1832                self.registry.set_show_closed(show_closed);
1833                Ok(json!({ "ok": true }))
1834            }
1835            "set-polling" => {
1836                // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1837                // poller resolving its badges (default is off — zero `gh`);
1838                // disabling stops it and drops its badges. `set_polling` bumps the
1839                // change-notify on a real change, so every subscribed window
1840                // re-pushes a `tree` snapshot carrying the new per-repo
1841                // `polling_enabled` — the reliable cross-window sync the
1842                // `set-show-closed` precedent relies on. A changed value is
1843                // persisted so it survives a daemon restart.
1844                let owner = require_str(&payload, "owner", "set-polling")?;
1845                let name = require_str(&payload, "name", "set-polling")?;
1846                let enabled = payload
1847                    .get("enabled")
1848                    .and_then(Value::as_bool)
1849                    .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1850                if owner.trim().is_empty() || name.trim().is_empty() {
1851                    bail!("`set-polling` requires a non-empty `owner` and `name`");
1852                }
1853                if self.registry.set_polling(owner, name, enabled) {
1854                    self.persist_polling_prefs();
1855                }
1856                Ok(json!({ "ok": true }))
1857            }
1858            "open-prs" => {
1859                // Serve "Open Pull Request…" (and the extension's transient badge
1860                // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1861                // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1862                // call instead of each shelling its own (the per-window burn
1863                // #1370/#1389 target). Repo-wide; the client filters by branch for a
1864                // worktree-scoped lookup, and answers a badged branch straight from
1865                // the snapshot (zero `gh`) without ever reaching here.
1866                let owner = require_str(&payload, "owner", "open-prs")?;
1867                let name = require_str(&payload, "name", "open-prs")?;
1868                if owner.trim().is_empty() || name.trim().is_empty() {
1869                    bail!("`open-prs` requires a non-empty `owner` and `name`");
1870                }
1871                Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1872            }
1873            "open" => {
1874                // Focus (or open — VS Code reuses an already-open window) an
1875                // arbitrary worktree folder supplied by a socket client, reusing
1876                // the tray's launcher path: `focus_window` resolves the launcher
1877                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1878                // the absolute-existing-directory guard (which also blocks a
1879                // `-`-leading path being parsed by `code` as a flag). This is the
1880                // one op a socket *writer* can use to spawn `code`; see the
1881                // ADR-0040 threat model (#1266).
1882                let path = require_str(&payload, "path", "open")?;
1883                focus_window(Path::new(path))?;
1884                Ok(json!({ "ok": true }))
1885            }
1886            "close" => {
1887                // Close a worktree's window and (for a linked worktree)
1888                // **delete** it. Destructive, so all git logic stays in the
1889                // daemon (git2, never a shell) and the main working tree is
1890                // refused defensively — the UI gating is not the only guard.
1891                // See ADR-0049 and docs/worktrees-service.md.
1892                let req: CloseRequest =
1893                    serde_json::from_value(payload).context("invalid `close` payload")?;
1894                self.close(req).await
1895            }
1896            "reload" => {
1897                // Signal each target window to reload itself (#1417). Addressed
1898                // by window key like `reposition`, not by path like `close`: a
1899                // reload acts on a *window*, and one tree row is one window,
1900                // whereas a path can be open in several. Nothing here is
1901                // destructive and nothing waits — the directive is marked and
1902                // the reply says only what was *signalled*. See
1903                // docs/worktrees-service.md.
1904                let req: ReloadRequest =
1905                    serde_json::from_value(payload).context("invalid `reload` payload")?;
1906                Ok(self.reload(req))
1907            }
1908            "merge-queue" => {
1909                // Batch-enqueue eligible worktrees' PRs into the GitHub merge
1910                // queue (#1401). Two-phase like `close` (side-effect-free
1911                // eligibility check → confirmed enqueue) and daemon-re-validated,
1912                // but a single batched op over `paths`. All git/`gh` work runs on
1913                // a blocking thread; enqueue authenticates through the user's own
1914                // `gh`. See ADR-0056 and docs/worktrees-service.md.
1915                let req: MergeQueueRequest =
1916                    serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1917                self.merge_queue(req).await
1918            }
1919            "rebase" => {
1920                // Batch-rebase the selected worktrees onto their repo's remote
1921                // default branch, fetching once per repo (#1415). Two-phase like
1922                // `close`/`merge-queue` (side-effect-free plan → confirmed
1923                // execute) and daemon-re-validated. The fetch authenticates
1924                // through the user's own `ssh-agent`, which launchd exports into
1925                // the daemon's environment — the premise ADR-0055 got wrong. All
1926                // git work runs on a blocking thread. See ADR-0059, ADR-0055 and
1927                // docs/worktrees-service.md.
1928                let req: RebaseRequest =
1929                    serde_json::from_value(payload).context("invalid `rebase` payload")?;
1930                self.rebase(req).await
1931            }
1932            "push" => {
1933                // Publish the selected worktrees' branches to their upstreams,
1934                // force-pushing **with a lease** where a rebase rewrote history
1935                // (#1443). Two-phase like `rebase` (side-effect-free plan →
1936                // confirmed execute) and daemon-re-validated, but its plan phase
1937                // contacts no remote at all. The push authenticates through the
1938                // user's own ambient git credentials — the first op to *write* to
1939                // a remote that way. There is no force escape hatch and no remote
1940                // override: a refused lease is the feature working. See ADR-0061
1941                // and docs/worktrees-service.md.
1942                let req: PushRequest =
1943                    serde_json::from_value(payload).context("invalid `push` payload")?;
1944                self.push(req).await
1945            }
1946            "reposition" => {
1947                // Move each target's already-open VS Code window onto the invoking
1948                // window's geometry (#1407). The one op that reaches outside the
1949                // process to control another application's windows, so all of its
1950                // OS interaction is confined to the `geometry::ax` module behind
1951                // the macOS Accessibility permission — geometry only, never a
1952                // raise, so Z-order is untouched. All AX I/O runs on a blocking
1953                // thread. See ADR-0058 and docs/worktrees-service.md.
1954                let req: RepositionRequest =
1955                    serde_json::from_value(payload).context("invalid `reposition` payload")?;
1956                self.reposition(req).await
1957            }
1958            "reposition-undo" => {
1959                // Put the windows the last `reposition` moved back where they were
1960                // (#1407). Payload-free: the daemon holds the one-level undo
1961                // record, so the client cannot ask to restore arbitrary geometry.
1962                self.reposition_undo().await
1963            }
1964            other => bail!("unknown worktrees op: {other}"),
1965        }
1966    }
1967
1968    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1969        // The single streaming op: a live push of the repo/worktree `tree`
1970        // snapshot. Every other op falls through to the request→reply `handle`.
1971        if op != "subscribe" {
1972            return None;
1973        }
1974        Some(Box::new(WorktreesStream {
1975            // Every stream reads through the one shared cache, so N windows
1976            // sampling the same tick build the tree once, not N times (#1303).
1977            cache: self.tree_cache.clone(),
1978            // Capture the change source *now* — before the server takes its
1979            // initial snapshot — so a change racing that snapshot still wakes us.
1980            changes: self.registry.subscribe_changes(),
1981        }))
1982    }
1983
1984    fn menu(&self) -> MenuSnapshot {
1985        // Serve the snapshot the background task maintains off the main thread;
1986        // fall back to a one-off inline compute only before the first refresh
1987        // lands (or with no runtime — the unit tests). Never blocks on git here
1988        // in the daemon, honouring the trait's "cheap, must not block" contract.
1989        let cached = self
1990            .menu_cache
1991            .lock()
1992            .unwrap_or_else(PoisonError::into_inner)
1993            .clone();
1994        let items = cached.unwrap_or_else(|| {
1995            menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1996        });
1997        MenuSnapshot {
1998            title: SUBMENU_TITLE.to_string(),
1999            items,
2000        }
2001    }
2002
2003    async fn menu_action(&self, action_id: &str) -> Result<()> {
2004        if let Some(key) = action_id.strip_prefix("focus:") {
2005            // The registry resolves the folder under its own lock and clones it
2006            // out, so the mutex is never held across the process launch.
2007            let folder = self
2008                .registry
2009                .first_folder(key)
2010                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2011            focus_window(&folder)?;
2012            return Ok(());
2013        }
2014        bail!("unknown worktrees menu action: {action_id}")
2015    }
2016
2017    async fn status(&self) -> ServiceStatus {
2018        let entries = self.registry.list();
2019        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2020        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2021        let windows = enriched_windows(entries).await;
2022        ServiceStatus {
2023            name: SERVICE_NAME.to_string(),
2024            healthy: true,
2025            summary,
2026            detail: json!({ "windows": windows }),
2027        }
2028    }
2029
2030    async fn shutdown(&self) {
2031        // Stop the background menu-refresh task; the registry itself is in-memory
2032        // with nothing to drain or persist. Take the task out from under the lock
2033        // first so the `std::Mutex` is never held across the `.await`.
2034        let task = self
2035            .refresh
2036            .lock()
2037            .unwrap_or_else(PoisonError::into_inner)
2038            .take();
2039        if let Some(task) = task {
2040            task.token.cancel();
2041            let _ = task.handle.await;
2042        }
2043        // Same discipline for the PR badge poller (#1337): take it out from under
2044        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
2045        let poller = self
2046            .poller
2047            .lock()
2048            .unwrap_or_else(PoisonError::into_inner)
2049            .take();
2050        if let Some(poller) = poller {
2051            poller.token.cancel();
2052            let _ = poller.handle.await;
2053        }
2054        // And the GitHub rate-limit poller (#1375), same discipline.
2055        let rate_limit_poller = self
2056            .rate_limit_poller
2057            .lock()
2058            .unwrap_or_else(PoisonError::into_inner)
2059            .take();
2060        if let Some(poller) = rate_limit_poller {
2061            poller.token.cancel();
2062            let _ = poller.handle.await;
2063        }
2064    }
2065}
2066
2067/// Extracts a required string `field` from an op payload, erroring with the op
2068/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
2069/// (`key`) and `open` (`path`).
2070fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2071    payload
2072        .get(field)
2073        .and_then(Value::as_str)
2074        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2075}
2076
2077/// The live git state of a worktree folder: the checked-out branch and how far
2078/// it has diverged from its upstream. Computed on read from the on-disk repo
2079/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
2080/// snapshot taken at registration.
2081///
2082/// Every field is optional and degrades independently: a folder that is not a
2083/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
2084/// listed — just without the fields it cannot supply. The `skip_serializing_if`
2085/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
2086/// omitting each absent field on the wire.
2087#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2088struct GitStatus {
2089    /// The checked-out branch, or `None` when detached or not in a repo.
2090    #[serde(skip_serializing_if = "Option::is_none")]
2091    branch: Option<String>,
2092    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
2093    /// even on a detached HEAD, which has a commit but no branch. Rides the
2094    /// streamed snapshot so a new commit is a real delta the server's diff cannot
2095    /// drop — without it, a push serialises byte-identically and no client
2096    /// re-renders (#1337).
2097    #[serde(skip_serializing_if = "Option::is_none")]
2098    head_sha: Option<String>,
2099    /// The commit the branch's configured upstream ref points at, or `None`
2100    /// without an upstream (or when detached, unborn, or not in a repo). Rides
2101    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
2102    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
2103    /// field — `head_sha` included — byte-identical, so without this the frame
2104    /// serialised the same, the server's diff dropped it, and the lazily-fetched
2105    /// ahead/behind was never re-asked (#1344).
2106    #[serde(skip_serializing_if = "Option::is_none")]
2107    upstream_sha: Option<String>,
2108    /// Commits the branch is ahead of its upstream (`None` without an upstream).
2109    #[serde(skip_serializing_if = "Option::is_none")]
2110    ahead: Option<usize>,
2111    /// Commits the branch is behind its upstream (`None` without an upstream).
2112    #[serde(skip_serializing_if = "Option::is_none")]
2113    behind: Option<usize>,
2114    /// The main repository's directory name — the parent repo for a linked
2115    /// worktree, the checkout's own directory otherwise. Derived from git's
2116    /// common dir so a worktree names the repo it belongs to rather than its
2117    /// worktree-folder basename. `None` when not in a repo.
2118    #[serde(skip_serializing_if = "Option::is_none")]
2119    main_repo: Option<String>,
2120    /// Whether the enriched folder is a **linked** git worktree rather than the
2121    /// repository's main working tree. Omitted (false) for a normal checkout.
2122    #[serde(skip_serializing_if = "is_false")]
2123    is_worktree: bool,
2124    /// The multi-step git operation the worktree is in the middle of (#1415):
2125    /// `rebase`, `rebase-interactive`, `merge`, `cherry-pick`, `revert`, `bisect`
2126    /// or `apply-mailbox`. `None` for a clean worktree (the overwhelming case), so
2127    /// the field is omitted on the wire and an older client is byte-identical.
2128    ///
2129    /// This is the **durable** half of the tree's rebase cue: read fresh off disk
2130    /// on every snapshot, it survives a daemon restart and keeps showing a
2131    /// conflict the `rebase` op left in place until the user resolves it. The
2132    /// transient half — "the daemon is rebasing this right now" — comes from the
2133    /// registry's in-memory set instead (see [`TreeWorktree::rebasing`]).
2134    ///
2135    /// Cheap enough for the every-worktree-every-tick snapshot (#1306's bar):
2136    /// `Repository::state()` stats a handful of paths under `.git`, which is
2137    /// nothing beside the `Repository::discover` this function already does — and
2138    /// unlike `graph_ahead_behind` it is neither a revwalk nor an object lookup.
2139    #[serde(skip_serializing_if = "Option::is_none")]
2140    operation: Option<String>,
2141}
2142
2143/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
2144/// field is dropped on the wire unless set — keeping older clients byte-identical
2145/// (the protocol's forward-compatibility convention).
2146#[allow(clippy::trivially_copy_pass_by_ref)]
2147fn is_false(b: &bool) -> bool {
2148    !*b
2149}
2150
2151/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
2152/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
2153/// daemon restart within the lease window keep the *remaining* time rather than
2154/// resetting the 15-minute clock; an already-expired entry is dropped on load.
2155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2156struct PollingLease {
2157    repo: String,
2158    expires_at: DateTime<Utc>,
2159}
2160
2161/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
2162/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
2163/// absence means not-polled (the default-off model) — so the file stays small (a
2164/// handful of active repos out of many open). `#[serde(default)]` so an
2165/// empty/older file decodes to "nothing enabled".
2166#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2167struct PollingPrefs {
2168    #[serde(default)]
2169    enabled: Vec<PollingLease>,
2170}
2171
2172/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
2173/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
2174/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
2175fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2176    if let Some(parent) = path.parent() {
2177        crate::daemon::paths::ensure_dir_0700(parent)?;
2178    }
2179    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2180    crate::daemon::paths::write_file_0600(path, &json)
2181}
2182
2183// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
2184
2185/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
2186///
2187/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
2188/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
2189/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
2190/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
2191/// compared against the worktree's current HEAD via
2192/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
2193/// `head_oid` would render every restored badge stale — so it carries the field
2194/// explicitly under a stable snake_case name.
2195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2196struct PersistedBadge {
2197    number: u64,
2198    is_draft: bool,
2199    checks: PrCheckState,
2200    url: String,
2201    head_oid: String,
2202}
2203
2204/// A persisted resolution — the disk twin of
2205/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
2206/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
2207/// `{ "Pr": { … } }`.
2208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2209enum PersistedResolution {
2210    Pr(PersistedBadge),
2211    NoPr,
2212}
2213
2214/// One persisted cache entry: which target, and the verdict last resolved for it.
2215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2216struct PersistedEntry {
2217    target: PrTarget,
2218    resolution: PersistedResolution,
2219}
2220
2221/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
2222/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
2223/// immediate re-poll" from "a target was added or a branch pushed while we were
2224/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
2225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2226struct PersistedWatch {
2227    target: PrTarget,
2228    #[serde(default, skip_serializing_if = "Option::is_none")]
2229    upstream_sha: Option<String>,
2230}
2231
2232/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
2233/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
2234/// so an empty/older/partial file decodes to "nothing restored" rather than
2235/// failing the load — best-effort, exactly like [`PollingPrefs`].
2236#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2237struct PrCachePrefs {
2238    #[serde(default)]
2239    entries: Vec<PersistedEntry>,
2240    #[serde(default)]
2241    watched: Vec<PersistedWatch>,
2242    #[serde(default, skip_serializing_if = "Option::is_none")]
2243    polled_at: Option<DateTime<Utc>>,
2244}
2245
2246impl PersistedResolution {
2247    /// The disk form of a live resolution.
2248    fn from_resolution(r: &PrResolution) -> Self {
2249        match r {
2250            PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2251                number: b.number,
2252                is_draft: b.is_draft,
2253                checks: b.checks,
2254                url: b.url.clone(),
2255                head_oid: b.head_oid.clone(),
2256            }),
2257            PrResolution::NoPr => Self::NoPr,
2258        }
2259    }
2260
2261    /// The live form of a restored resolution.
2262    fn into_resolution(self) -> PrResolution {
2263        match self {
2264            Self::Pr(b) => PrResolution::Pr(PrBadge {
2265                number: b.number,
2266                is_draft: b.is_draft,
2267                checks: b.checks,
2268                url: b.url,
2269                head_oid: b.head_oid,
2270            }),
2271            Self::NoPr => PrResolution::NoPr,
2272        }
2273    }
2274}
2275
2276/// Assembles the on-disk cache from the live cache entries, the watch they were
2277/// resolved for, and the poll time.
2278fn pr_cache_prefs_from(
2279    entries: Vec<(PrTarget, PrResolution)>,
2280    watched: &[PrWatch],
2281    polled_at: DateTime<Utc>,
2282) -> PrCachePrefs {
2283    let mut entries: Vec<PersistedEntry> = entries
2284        .into_iter()
2285        .map(|(target, resolution)| PersistedEntry {
2286            target,
2287            resolution: PersistedResolution::from_resolution(&resolution),
2288        })
2289        .collect();
2290    // Stable on-disk order so the file does not churn on rewrite from `HashMap`
2291    // iteration order alone (the same reason `PollingPrefs` sorts).
2292    entries.sort_by(|a, b| a.target.cmp(&b.target));
2293    let mut watched: Vec<PersistedWatch> = watched
2294        .iter()
2295        .map(|w| PersistedWatch {
2296            target: w.target.clone(),
2297            upstream_sha: w.upstream_sha.clone(),
2298        })
2299        .collect();
2300    watched.sort_by(|a, b| a.target.cmp(&b.target));
2301    PrCachePrefs {
2302        entries,
2303        watched,
2304        polled_at: Some(polled_at),
2305    }
2306}
2307
2308/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
2309/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
2310fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2311    if let Some(parent) = path.parent() {
2312        crate::daemon::paths::ensure_dir_0700(parent)?;
2313    }
2314    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2315    crate::daemon::paths::write_file_0600(path, &json)
2316}
2317
2318/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
2319/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
2320/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
2321/// in-memory cache is authoritative for the running daemon and losing the warm
2322/// start only costs one extra poll after the next restart.
2323fn persist_pr_cache(
2324    path: &Path,
2325    pr_cache: &PrStatusCache,
2326    watched: &[PrWatch],
2327    polled_at: DateTime<Utc>,
2328) {
2329    let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2330    if let Err(err) = write_pr_cache(path, &prefs) {
2331        let at = path.display();
2332        tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2333    }
2334}
2335
2336/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
2337/// the watch set the previous daemon last resolved and when it last polled. The
2338/// poller seeds its loop from this so a restart within the backoff window skips
2339/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
2340/// call to re-derive what the `0600` file already carries.
2341#[derive(Debug, Clone)]
2342struct PrWarmStart {
2343    /// The `(target, upstream_sha)` set the persisted verdicts describe.
2344    watched: Vec<PrWatch>,
2345    /// When those verdicts were resolved, used to age the warm start against the
2346    /// backoff (a stale-enough file just re-polls).
2347    polled_at: DateTime<Utc>,
2348}
2349
2350// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
2351
2352/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
2353/// fetched, for TTL expiry.
2354#[derive(Debug, Clone)]
2355struct OpenPrEntry {
2356    at: Instant,
2357    prs: Vec<Value>,
2358}
2359
2360/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
2361///
2362/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
2363/// from the daemon means N windows asking about one repo dedupe to a single counted
2364/// `gh pr list` within the TTL, instead of each window shelling its own (the
2365/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
2366/// the access pattern is a manual action (or a brief post-enable transient), so two
2367/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
2368/// and harmless, while the common repeat-within-TTL is served for free. The lock is
2369/// never held across an `.await`.
2370#[derive(Debug)]
2371struct OpenPrCache {
2372    entries: Mutex<HashMap<String, OpenPrEntry>>,
2373    ttl: Duration,
2374}
2375
2376impl OpenPrCache {
2377    fn new(ttl: Duration) -> Self {
2378        Self {
2379            entries: Mutex::new(HashMap::new()),
2380            ttl,
2381        }
2382    }
2383
2384    /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
2385    /// `None` (a miss that the caller resolves with a fresh `gh`).
2386    fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2387        self.entries
2388            .lock()
2389            .unwrap_or_else(PoisonError::into_inner)
2390            .get(key)
2391            .filter(|e| e.at.elapsed() < self.ttl)
2392            .map(|e| e.prs.clone())
2393    }
2394
2395    /// Records a freshly-fetched PR list for `key`.
2396    fn store(&self, key: String, prs: Vec<Value>) {
2397        self.entries
2398            .lock()
2399            .unwrap_or_else(PoisonError::into_inner)
2400            .insert(
2401                key,
2402                OpenPrEntry {
2403                    at: Instant::now(),
2404                    prs,
2405                },
2406            );
2407    }
2408}
2409
2410/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
2411/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
2412/// call on a blocking thread, never an async worker. The array is forwarded to the
2413/// extension verbatim, which parses it into its `PullRequest` shape.
2414fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2415    let output = crate::github_metrics::run_gh(
2416        bin,
2417        [
2418            "pr",
2419            "list",
2420            "--repo",
2421            slug,
2422            "--state",
2423            "open",
2424            "--json",
2425            OPEN_PR_JSON_FIELDS,
2426            "--limit",
2427            OPEN_PR_LIST_LIMIT,
2428        ],
2429        "pr list",
2430        None,
2431    )
2432    .with_context(|| {
2433        format!(
2434            "failed to run {} (is the GitHub CLI installed?)",
2435            bin.display()
2436        )
2437    })?;
2438    if !output.status.success() {
2439        let stderr = String::from_utf8_lossy(&output.stderr);
2440        bail!("gh pr list failed: {}", stderr.trim());
2441    }
2442    match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2443        Value::Array(arr) => Ok(arr),
2444        _ => bail!("gh pr list did not return a JSON array"),
2445    }
2446}
2447
2448/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
2449/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
2450/// op and the tray menu, both bounded to the (few) open windows, where the extra
2451/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
2452/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
2453fn git_status(folder: &Path) -> GitStatus {
2454    git_status_impl(folder, true)
2455}
2456
2457/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
2458/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
2459/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
2460/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
2461/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
2462/// fields stay `None`, so they are omitted on the wire exactly as for a branch
2463/// with no upstream.
2464fn git_status_cheap(folder: &Path) -> GitStatus {
2465    git_status_impl(folder, false)
2466}
2467
2468/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
2469/// repository that contains `folder` — so a subdirectory or a linked worktree both
2470/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
2471/// divergence. Every failure mode degrades to an empty status rather than
2472/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
2473fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2474    let Ok(repo) = Repository::discover(folder) else {
2475        return GitStatus::default();
2476    };
2477    // Repo identity applies even when HEAD is unborn or detached, so a worktree
2478    // still names its parent repo (and is flagged as a worktree) in those states.
2479    // The in-progress operation is read here too, for the same reason: a worktree
2480    // mid-rebase has a *detached* HEAD, so reading it any later would miss the one
2481    // state the cue exists to show.
2482    let base = GitStatus {
2483        main_repo: main_repo_name(repo.commondir()),
2484        is_worktree: repo.is_worktree(),
2485        operation: operation_slug(repo.state()),
2486        ..GitStatus::default()
2487    };
2488    let Ok(head) = repo.head() else {
2489        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
2490        return base;
2491    };
2492    // Resolved here — before the branch filter below, so a detached HEAD still
2493    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
2494    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
2495    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
2496    // rebuild (#1306's bar).
2497    let base = GitStatus {
2498        head_sha: head.target().map(|oid| oid.to_string()),
2499        ..base
2500    };
2501    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
2502    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
2503    // name — degrades to no branch through this one path.
2504    let Some(name) = head
2505        .shorthand()
2506        .ok()
2507        .filter(|_| head.is_branch())
2508        .map(str::to_string)
2509    else {
2510        return base;
2511    };
2512    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
2513    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
2514    // below costs the cheap path nothing, and is what gives it a handle to
2515    // resolve the upstream from.
2516    let branch = git2::Branch::wrap(head);
2517    // Unlike the divergence walk, this rides both paths: it is what makes a push
2518    // a visible delta (#1344).
2519    let upstream_sha = upstream_target(&branch);
2520    // The divergence walk is the dominant per-worktree cost, so the cheap path
2521    // skips it and leaves ahead/behind absent.
2522    let (ahead, behind) = if with_ahead_behind {
2523        match upstream_ahead_behind(&repo, &branch) {
2524            Some((ahead, behind)) => (Some(ahead), Some(behind)),
2525            None => (None, None),
2526        }
2527    } else {
2528        (None, None)
2529    };
2530    GitStatus {
2531        branch: Some(name),
2532        upstream_sha,
2533        ahead,
2534        behind,
2535        ..base
2536    }
2537}
2538
2539/// The stable kebab-case slug for a repository's in-progress operation, or `None`
2540/// when it is [`RepositoryState::Clean`] (#1415).
2541///
2542/// The three rebase flavours libgit2 distinguishes (`Rebase`, `RebaseInteractive`,
2543/// `RebaseMerge`) all collapse to `rebase-interactive` or `rebase`, because the
2544/// distinction is an implementation detail of how git is driving the rebase and
2545/// says nothing a user acting on the row would do differently. The `*Sequence`
2546/// variants likewise fold into their singular form. Everything a client does not
2547/// recognise still renders as "some operation in progress", which is the useful
2548/// floor.
2549fn operation_slug(state: RepositoryState) -> Option<String> {
2550    let slug = match state {
2551        RepositoryState::Clean => return None,
2552        RepositoryState::Merge => "merge",
2553        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2554        RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2555        RepositoryState::Bisect => "bisect",
2556        RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2557        RepositoryState::RebaseInteractive => "rebase-interactive",
2558        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2559    };
2560    Some(slug.to_string())
2561}
2562
2563/// The commit `branch`'s configured upstream ref points at, or `None` when it
2564/// tracks no upstream (or the ref is unresolvable).
2565///
2566/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
2567/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
2568/// refs read, but still **no revwalk and no object lookup**, which is the bar
2569/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
2570/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
2571/// same OID, so it is proven reachable.
2572fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2573    Some(branch.upstream().ok()?.get().target()?.to_string())
2574}
2575
2576/// The ahead/behind divergence of `folder`'s checked-out branch versus its
2577/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
2578/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
2579/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
2580/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
2581/// sync indicator.
2582fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
2583    let repo = Repository::discover(folder).ok()?;
2584    let head = repo.head().ok()?;
2585    if !head.is_branch() {
2586        return None;
2587    }
2588    let branch = git2::Branch::wrap(head);
2589    upstream_ahead_behind(&repo, &branch)
2590}
2591
2592/// Commits `folder`'s checked-out branch is behind the repository's remote
2593/// default branch (`origin/<main>`), computed on demand for the lazy
2594/// `ahead-behind` op (#1457). Resolves the default branch the same
2595/// **local-only, no-fetch** way `worktree_rebase::resolve_onto` resolves its
2596/// `--onto` default — via [`RemoteInfo::detect_main_branch_local`] — but,
2597/// unlike that resolver, never falls back to a hardcoded `"main"`: this is a
2598/// passive signal, so an unresolvable default branch means silence (`None`)
2599/// rather than a guess.
2600///
2601/// `None` when: `folder` is not a repo, HEAD is detached/unborn, no default
2602/// branch is locally resolvable, or the branch's own upstream **is** already
2603/// that default branch — in which case [`folder_ahead_behind`]'s `behind`
2604/// already reports this exact divergence, so repeating it here would just
2605/// duplicate the existing sync count.
2606fn folder_main_behind(folder: &Path) -> Option<usize> {
2607    let repo = Repository::discover(folder).ok()?;
2608    let head = repo.head().ok()?;
2609    if !head.is_branch() {
2610        return None;
2611    }
2612    let branch = git2::Branch::wrap(head);
2613
2614    let remote = "origin";
2615    let default_branch = RemoteInfo::detect_main_branch_local(&repo, remote)?;
2616    let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2617
2618    // Skip when the branch's own upstream already IS the resolved default
2619    // branch (the common checked-out-main/master case) — compared by ref name
2620    // so a fork's upstream on a *different* remote (e.g. `upstream/main`) is
2621    // never mistaken for it.
2622    if let Ok(upstream) = branch.upstream() {
2623        if upstream.get().name() == Ok(onto_ref.as_str()) {
2624            return None;
2625        }
2626    }
2627
2628    let head_oid = branch.get().target()?;
2629    let onto_oid = repo
2630        .revparse_single(&onto_ref)
2631        .ok()?
2632        .peel_to_commit()
2633        .ok()?
2634        .id();
2635    let (_ahead, behind) = repo.graph_ahead_behind(head_oid, onto_oid).ok()?;
2636    Some(behind)
2637}
2638
2639/// The main repository's directory name from git's common dir. For the usual
2640/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
2641/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
2642/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
2643/// no name can be derived.
2644fn main_repo_name(commondir: &Path) -> Option<String> {
2645    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2646    if file_name == ".git" {
2647        // Normal layout: the repo is the directory that contains `.git`.
2648        commondir
2649            .parent()
2650            .and_then(Path::file_name)
2651            .map(|n| n.to_string_lossy().into_owned())
2652    } else {
2653        // A bare repo: use its own directory name, without any `.git` suffix.
2654        Some(
2655            file_name
2656                .strip_suffix(".git")
2657                .unwrap_or(&file_name)
2658                .to_string(),
2659        )
2660    }
2661}
2662
2663/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2664/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2665fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2666    let upstream = branch.upstream().ok()?;
2667    let local_oid = branch.get().target()?;
2668    let upstream_oid = upstream.get().target()?;
2669    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2670}
2671
2672/// The wire shape of an enriched window: the stored entry fields plus the
2673/// daemon-computed git state, flattened into one JSON object. Serializing
2674/// through a single struct (rather than mutating a `Value`) keeps every present
2675/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2676/// the absent git fields — no manual per-field insertion.
2677#[derive(Serialize)]
2678struct EnrichedEntry<'a> {
2679    #[serde(flatten)]
2680    entry: &'a WindowEntry,
2681    #[serde(flatten)]
2682    git: GitStatus,
2683}
2684
2685/// Serializes a registry entry and folds in the live [`git_status`] of its
2686/// primary (first) folder, producing the JSON object served on the wire
2687/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2688/// enriched — it is the one the table shows and the "focus" action opens.
2689fn enriched_entry(entry: &WindowEntry) -> Value {
2690    let git = entry
2691        .folders
2692        .first()
2693        .map(|folder| git_status(folder))
2694        .unwrap_or_default();
2695    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2696}
2697
2698/// Enriches a batch of entries with their git state on a blocking thread, since
2699/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2700/// handler. A join failure degrades to an empty list rather than erroring.
2701async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2702    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2703        .await
2704        .unwrap_or_default()
2705}
2706
2707// --- Repo/worktree tree (#1265) ----------------------------------------------
2708
2709/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2710/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2711/// non-GitHub (or remote-less) repo omits it.
2712#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2713struct GithubIdentity {
2714    /// The repository owner (user or org) — the first path segment.
2715    owner: String,
2716    /// The repository name, with any `.git` suffix stripped.
2717    name: String,
2718}
2719
2720/// One worktree of a repository in the `tree` payload: its path, live git state,
2721/// whether it is the main working tree, and whether a VS Code window currently
2722/// has it open (with that window's key, for the focus action). Optional git
2723/// fields degrade independently, exactly like [`GitStatus`].
2724///
2725/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2726/// the dominant per-worktree cost when computed eagerly for every worktree on
2727/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2728/// worktrees a client actually shows (#1306).
2729///
2730/// The two **OIDs** the divergence is computed from — `head_sha` and
2731/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2732/// refs read rather than a commit-graph walk, and between them they are what makes
2733/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2734/// re-ask for the counts it left behind.
2735#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2736struct TreeWorktree {
2737    /// Absolute path to the worktree's working directory.
2738    path: String,
2739    /// The checked-out branch, or `None` when detached or unborn.
2740    #[serde(skip_serializing_if = "Option::is_none")]
2741    branch: Option<String>,
2742    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2743    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2744    /// commit a visible delta, so a push re-renders instead of being dropped by the
2745    /// server's snapshot diff (#1337).
2746    #[serde(skip_serializing_if = "Option::is_none")]
2747    head_sha: Option<String>,
2748    /// The commit the branch's upstream ref points at, or `None` without an
2749    /// upstream. The push counterpart of `head_sha`: a push moves only
2750    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2751    /// making the snapshot a real delta the server's diff cannot drop, which is
2752    /// what re-fetches the lazy ahead/behind (#1344).
2753    #[serde(skip_serializing_if = "Option::is_none")]
2754    upstream_sha: Option<String>,
2755    /// Whether this is the repository's main working tree (vs a linked worktree).
2756    is_main: bool,
2757    /// Whether a live VS Code window currently has this worktree open.
2758    open: bool,
2759    /// The open window's registry key, when `open` — the handle a focus action
2760    /// resolves. Absent for a worktree with no open window.
2761    #[serde(skip_serializing_if = "Option::is_none")]
2762    window_key: Option<String>,
2763    /// The open PR whose head is this worktree's branch, with its CI verdict
2764    /// (#1337). Resolved by the daemon's background poller and folded on as the
2765    /// snapshot is built, so every open window sees the same live state without
2766    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2767    /// with no open PR (see `pr_none`), or until the first poll lands.
2768    #[serde(skip_serializing_if = "Option::is_none")]
2769    pr: Option<PrBadge>,
2770    /// Set when the daemon **checked GitHub and found no open PR** for this
2771    /// worktree's branch — the explicit negative (#1370), mutually exclusive
2772    /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2773    /// non-GitHub worktree, and — crucially — while the branch is simply **not
2774    /// yet resolved** (before the first poll lands, or ever, on a failed one):
2775    /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2776    /// older client stays byte-identical (ADR-0053). Clients use it to keep
2777    /// their degraded per-window `gh pr list` fallback quiet for branches the
2778    /// daemon has already answered for.
2779    #[serde(skip_serializing_if = "is_false")]
2780    pr_none: bool,
2781    /// The multi-step git operation this worktree is mid-way through, when any
2782    /// (#1415) — see [`GitStatus::operation`]. The **durable** half of the rebase
2783    /// cue: a conflict the `rebase` op left in place shows here until it is
2784    /// resolved, across daemon restarts. Omitted for a clean worktree.
2785    #[serde(skip_serializing_if = "Option::is_none")]
2786    operation: Option<String>,
2787    /// Whether the daemon is rebasing this worktree **right now** (#1415) — the
2788    /// **transient** half of the cue, from the registry's in-memory set.
2789    ///
2790    /// Not redundant with `operation`: a rebase that applies cleanly never leaves
2791    /// an on-disk state for `operation` to report, and even one that conflicts
2792    /// only writes it at the moment of collision — so without this a multi-second
2793    /// rebase would render as nothing happening at all. Omitted (false) for the
2794    /// common case, keeping an older client byte-identical.
2795    #[serde(skip_serializing_if = "is_false")]
2796    rebasing: bool,
2797    /// Whether the daemon is pushing this worktree **right now** (#1443) — the
2798    /// `rebasing` twin, from the registry's other in-flight set.
2799    ///
2800    /// Unlike a rebase this cue has **no durable half**: a push writes no on-disk
2801    /// state, so there is nothing for a later snapshot to rediscover and this flag
2802    /// is the whole of it. A *completed* push instead shows up as `upstream_sha`
2803    /// moving, which is why that field rides the snapshot (#1344). Omitted (false)
2804    /// for the common case, keeping an older client byte-identical.
2805    #[serde(skip_serializing_if = "is_false")]
2806    pushing: bool,
2807}
2808
2809/// The registry's two transient in-flight sets, read together into one tree
2810/// snapshot (#1443).
2811///
2812/// Grouped rather than threaded as two parameters through
2813/// [`worktree_entry`]/[`repo_tree`]/[`build_tree`]/[`tree_repos`]: they are always
2814/// read at the same moment, from the same registry, for the same snapshot, and a
2815/// third would otherwise mean a fifth positional `HashSet` at every level.
2816#[derive(Debug, Clone, Default)]
2817struct InFlight {
2818    /// Worktree paths the `rebase` op is executing on (#1415).
2819    rebasing: HashSet<PathBuf>,
2820    /// Worktree paths the `push` op is executing on (#1443).
2821    pushing: HashSet<PathBuf>,
2822}
2823
2824impl InFlight {
2825    /// Reads both sets off the registry. Two short lock acquisitions, neither held
2826    /// across an `.await`; the pair need not be atomic, since each cue is
2827    /// independently true or false of a given row.
2828    fn read(registry: &WorktreesRegistry) -> Self {
2829        Self {
2830            rebasing: registry.rebasing_paths(),
2831            pushing: registry.pushing_paths(),
2832        }
2833    }
2834}
2835
2836/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2837/// derived from the distinct open windows; a repo leaves the tree when its last
2838/// window closes (the open-window-derived model, ADR-0040 / #1264).
2839#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2840struct TreeRepo {
2841    /// The main repository's directory name (see [`main_repo_name`]).
2842    main_repo: String,
2843    /// The GitHub identity of `origin`, when it is a `github.com` remote.
2844    #[serde(skip_serializing_if = "Option::is_none")]
2845    github: Option<GithubIdentity>,
2846    /// Absolute path to the main working tree — the repo's root.
2847    root: String,
2848    /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2849    /// registry's per-repo enable set, which defaults **off**, so it is omitted
2850    /// (false) for the common not-polled repo — keeping older clients
2851    /// byte-identical — and present (`true`) only for a repo the user has
2852    /// explicitly enabled. The extension colours the repo icon green when set and
2853    /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2854    /// on it so a not-polled repo issues zero `gh`.
2855    #[serde(skip_serializing_if = "is_false")]
2856    polling_enabled: bool,
2857    /// Every worktree of the repo: the main working tree first, then linked
2858    /// worktrees sorted by path.
2859    worktrees: Vec<TreeWorktree>,
2860}
2861
2862/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2863/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2864/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2865/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2866/// slashes are stripped; anything with an empty or extra path segment is
2867/// rejected (best-effort, never panics).
2868fn github_identity(url: &str) -> Option<GithubIdentity> {
2869    let url = url.trim();
2870    // Reduce every supported form to the `owner/name…` tail after the host.
2871    let rest = [
2872        "https://github.com/",
2873        "http://github.com/",
2874        "ssh://git@github.com/",
2875        "git://github.com/",
2876        "git@github.com:",
2877    ]
2878    .iter()
2879    .find_map(|prefix| url.strip_prefix(prefix))?;
2880    let rest = rest.strip_suffix(".git").unwrap_or(rest);
2881    let rest = rest.trim_end_matches('/');
2882    let mut parts = rest.splitn(2, '/');
2883    let owner = parts.next()?.trim();
2884    let name = parts.next()?.trim();
2885    // A well-formed identity has exactly two non-empty segments.
2886    if owner.is_empty() || name.is_empty() || name.contains('/') {
2887        return None;
2888    }
2889    Some(GithubIdentity {
2890        owner: owner.to_string(),
2891        name: name.to_string(),
2892    })
2893}
2894
2895/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2896/// `github.com` remote found. `None` when no remote is a GitHub remote.
2897fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2898    if let Ok(origin) = repo.find_remote("origin") {
2899        if let Some(id) = origin.url().ok().and_then(github_identity) {
2900            return Some(id);
2901        }
2902    }
2903    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2904    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2905    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2906    let names = repo.remotes().ok();
2907    names
2908        .iter()
2909        .flat_map(|arr| arr.iter())
2910        .flatten()
2911        .flatten()
2912        .filter_map(|name| repo.find_remote(name).ok())
2913        .find_map(|remote| remote.url().ok().and_then(github_identity))
2914}
2915
2916/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2917/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2918/// no longer exists) so the join still degrades gracefully.
2919fn canonical(path: &Path) -> PathBuf {
2920    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2921}
2922
2923/// Indexes the open windows by canonicalized workspace-folder path → window key,
2924/// so a worktree path can be joined back to the window (if any) that has it open.
2925/// The first window wins a shared folder; `entries` arrive in a deterministic
2926/// (repo, key) order, so the choice is stable.
2927fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2928    let mut index = HashMap::new();
2929    for entry in entries {
2930        for folder in &entry.folders {
2931            index
2932                .entry(canonical(folder))
2933                .or_insert_with(|| entry.key.clone());
2934        }
2935    }
2936    index
2937}
2938
2939/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2940/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2941/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2942/// by the caller from the enumeration (main working tree vs linked).
2943fn worktree_entry(
2944    path: &Path,
2945    is_main: bool,
2946    open_index: &HashMap<PathBuf, String>,
2947    in_flight: &InFlight,
2948) -> TreeWorktree {
2949    let status = git_status_cheap(path);
2950    let canonical = canonical(path);
2951    let window_key = open_index.get(&canonical).cloned();
2952    TreeWorktree {
2953        path: path.display().to_string(),
2954        branch: status.branch,
2955        head_sha: status.head_sha,
2956        upstream_sha: status.upstream_sha,
2957        is_main,
2958        open: window_key.is_some(),
2959        window_key,
2960        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2961        // identity — known one level up, in `repo_tree`.
2962        pr: None,
2963        pr_none: false,
2964        operation: status.operation,
2965        // Every in-flight cue is joined on the *canonical* path: the registry sets
2966        // are canonicalized by the adapter when an op marks them.
2967        rebasing: in_flight.rebasing.contains(&canonical),
2968        pushing: in_flight.pushing.contains(&canonical),
2969    }
2970}
2971
2972/// Folds the poller's cached PR resolutions onto each worktree of each repo
2973/// (#1337).
2974///
2975/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2976/// identity, branch) and the identity is only known once the repo is assembled.
2977/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2978/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
2979/// keeps `pr: None`/`pr_none: false` and renders nothing.
2980///
2981/// A verdict computed for a **different commit** than the worktree has checked out
2982/// is downgraded to pending here rather than shown as-is. That is what makes a push
2983/// invalidate the badge the moment it happens: the cache still holds the previous
2984/// commit's verdict, and this fold — which runs on every snapshot — notices without
2985/// waiting for a poll. Without it the previous head's `✓` stands until the poller
2986/// next runs, which is up to the full backoff.
2987///
2988/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
2989/// when `head_sha` moves: it has no commit to be stale against, and dropping it
2990/// would re-arm every client's `gh` fallback on every local commit. The poller's
2991/// `moved` trigger re-checks the branch within one fast poll anyway.
2992/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
2993/// enable set (#1376).
2994///
2995/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
2996/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
2997/// enabled carries neither the flag nor any badge. Purely a set membership check —
2998/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
2999/// and stays `false`; it never polls anyway.
3000fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3001    for repo in repos {
3002        if let Some(github) = &repo.github {
3003            repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3004        }
3005    }
3006}
3007
3008fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3009    for repo in repos {
3010        // A not-polled repo (#1376) never carries a badge: skip it so a repo the
3011        // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
3012        // the flag, and so the icon greys cross-window on the next pushed snapshot.
3013        if !repo.polling_enabled {
3014            continue;
3015        }
3016        let Some(github) = repo.github.clone() else {
3017            continue;
3018        };
3019        for worktree in &mut repo.worktrees {
3020            let Some(branch) = &worktree.branch else {
3021                continue;
3022            };
3023            match pr_cache.get(&github.owner, &github.name, branch) {
3024                Some(PrResolution::Pr(mut badge)) => {
3025                    if badge.is_stale_for(worktree.head_sha.as_deref()) {
3026                        badge.checks = PrCheckState::Pending;
3027                    }
3028                    worktree.pr = Some(badge);
3029                }
3030                Some(PrResolution::NoPr) => worktree.pr_none = true,
3031                None => {}
3032            }
3033        }
3034    }
3035}
3036
3037/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
3038/// handle discovered from one of its folders. Opens the **main** repo from the
3039/// shared common dir's parent so the main working tree and every linked worktree
3040/// are enumerated regardless of which one seeded the discovery. `None` for a
3041/// bare or otherwise root-less repo (no working tree to show).
3042fn repo_tree(
3043    discovered: &Repository,
3044    open_index: &HashMap<PathBuf, String>,
3045    in_flight: &InFlight,
3046) -> Option<TreeRepo> {
3047    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
3048    // linked worktrees; its parent is the main working tree.
3049    let commondir = canonical(discovered.commondir());
3050    let main_root = commondir.parent()?.to_path_buf();
3051    let main_repo = Repository::open(&main_root).ok()?;
3052
3053    // Main working tree first.
3054    let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3055    // Then every linked worktree, sorted by path for deterministic output. The
3056    // `StringArray` of names is bound so `iter()` can borrow it (only
3057    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
3058    // worktree is skipped.
3059    let names = main_repo.worktrees().ok();
3060    let mut linked: Vec<PathBuf> = names
3061        .iter()
3062        .flat_map(|arr| arr.iter())
3063        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3064        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3065        .filter_map(|name| main_repo.find_worktree(name).ok())
3066        .map(|wt| wt.path().to_path_buf())
3067        .collect();
3068    linked.sort();
3069    worktrees.extend(
3070        linked
3071            .iter()
3072            .map(|path| worktree_entry(path, false, open_index, in_flight)),
3073    );
3074
3075    Some(TreeRepo {
3076        main_repo: main_repo_name(&commondir)?,
3077        github: remote_github_identity(&main_repo),
3078        root: main_root.display().to_string(),
3079        // Defaults off; `stamp_polling` sets it from the registry's enable set
3080        // once the repo (and thus its GitHub identity) is assembled.
3081        polling_enabled: false,
3082        worktrees,
3083    })
3084}
3085
3086/// Resolves the seed `folders` to their distinct repositories and enumerates
3087/// each repo's worktrees. Dedupes repos by their common dir (shared across a
3088/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
3089/// is not in a git repo is skipped. Pure blocking git I/O — call it via
3090/// [`tree_repos`], never under the registry lock.
3091fn build_tree(
3092    folders: Vec<PathBuf>,
3093    windows: Vec<WindowEntry>,
3094    in_flight: InFlight,
3095) -> Vec<TreeRepo> {
3096    let open_index = open_window_index(&windows);
3097    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3098    for folder in &folders {
3099        let Ok(repo) = Repository::discover(folder) else {
3100            continue;
3101        };
3102        let key = canonical(repo.commondir());
3103        if repos.contains_key(&key) {
3104            continue;
3105        }
3106        if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3107            repos.insert(key, tree);
3108        }
3109    }
3110    repos.into_values().collect()
3111}
3112
3113/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
3114/// does synchronous disk I/O and this runs inside the async control-socket
3115/// handler), returning the serialized `repos` array. A join failure degrades to
3116/// an empty list rather than erroring, matching [`enriched_windows`].
3117async fn tree_repos(
3118    folders: Vec<PathBuf>,
3119    windows: Vec<WindowEntry>,
3120    pr_cache: Arc<PrStatusCache>,
3121    enabled_polling: HashSet<String>,
3122    in_flight: InFlight,
3123) -> Vec<Value> {
3124    tokio::task::spawn_blocking(move || {
3125        let mut repos = build_tree(folders, windows, in_flight);
3126        // Stamp per-repo poll state first so `fold_pr_badges` can skip a
3127        // not-polled repo — a disabled repo carries neither the flag nor a badge.
3128        stamp_polling(&mut repos, &enabled_polling);
3129        fold_pr_badges(&mut repos, &pr_cache);
3130        repos
3131            .iter()
3132            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3133            .collect()
3134    })
3135    .await
3136    .unwrap_or_default()
3137}
3138
3139// --- Lazy ahead/behind (#1306) -----------------------------------------------
3140
3141/// The wire shape of one worktree's lazily-fetched divergence: `ahead`/`behind`
3142/// (from its own upstream, folded in together or not at all — they come from one
3143/// `graph_ahead_behind` call) and `main_behind` (from the repo's remote default
3144/// branch, #1457), each independently optional. `main_behind` can be present
3145/// when `ahead`/`behind` are absent (no upstream at all) or absent when they are
3146/// present (the branch's own upstream already *is* the default branch).
3147#[derive(Serialize)]
3148struct AheadBehindEntry {
3149    #[serde(skip_serializing_if = "Option::is_none")]
3150    ahead: Option<usize>,
3151    #[serde(skip_serializing_if = "Option::is_none")]
3152    behind: Option<usize>,
3153    #[serde(skip_serializing_if = "Option::is_none")]
3154    main_behind: Option<usize>,
3155}
3156
3157/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
3158/// returning a JSON object keyed by the **requested** path string:
3159/// `{ "<path>": { "ahead"?, "behind"?, "main_behind"? }, … }`. A row is **omitted**
3160/// when neither the upstream divergence nor the main-branch divergence resolves
3161/// (not a repo, detached/unborn HEAD) — the client renders it without a sync
3162/// indicator, exactly as before #1457. A row can otherwise carry any subset of the
3163/// three fields: `main_behind` alone (no upstream, but behind the default branch),
3164/// `ahead`/`behind` alone (upstream *is* the default branch, so `main_behind` is
3165/// skipped), or all three together.
3166///
3167/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
3168/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
3169/// shows (the extension on expand), not for every worktree on every tick. The git
3170/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
3171/// degrades to an empty object rather than erroring.
3172async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
3173    tokio::task::spawn_blocking(move || {
3174        let mut results = serde_json::Map::new();
3175        for path in paths {
3176            let (ahead, behind) =
3177                folder_ahead_behind(&path).map_or((None, None), |(a, b)| (Some(a), Some(b)));
3178            let main_behind = folder_main_behind(&path);
3179            if ahead.is_none() && main_behind.is_none() {
3180                continue;
3181            }
3182            results.insert(
3183                path.display().to_string(),
3184                json!(AheadBehindEntry {
3185                    ahead,
3186                    behind,
3187                    main_behind,
3188                }),
3189            );
3190        }
3191        Value::Object(results)
3192    })
3193    .await
3194    .unwrap_or_else(|_| json!({}))
3195}
3196
3197// --- Push subscription (#1267) -----------------------------------------------
3198
3199/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
3200/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
3201/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
3202/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
3203/// type only has to (a) relay the registry's change-notify and (b) read the
3204/// tree snapshot on demand.
3205///
3206/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
3207/// is built at most once per tick and fanned out, rather than each stream
3208/// rebuilding the identical tree. This type holds only cheap handles — a clone
3209/// of the shared cache and its own change-notify receiver.
3210struct WorktreesStream {
3211    /// The shared coalescing cache the snapshot is read through, so every
3212    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
3213    cache: Arc<TreeSnapshotCache>,
3214    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
3215    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
3216    /// server's diff drops any snapshot that ends up identical.
3217    changes: watch::Receiver<u64>,
3218}
3219
3220#[async_trait]
3221impl ServiceStream for WorktreesStream {
3222    async fn changed(&mut self) {
3223        // `watch::Receiver::changed` marks the newest version seen, so a burst of
3224        // bumps collapses into a single wakeup. If every sender is gone (the
3225        // registry — and thus the daemon — is tearing down) it returns `Err`;
3226        // park instead of returning, so this arm can never spin the server's
3227        // `select!` (the tick and shutdown arms still drive teardown).
3228        if self.changes.changed().await.is_err() {
3229            std::future::pending::<()>().await;
3230        }
3231    }
3232
3233    async fn snapshot(&self) -> Value {
3234        // Read through the shared coalescing cache. The value is built by the
3235        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
3236        // live push agree byte-for-byte — but here it is built once per tick and
3237        // shared across every subscriber rather than rebuilt per stream (#1303).
3238        self.cache.snapshot().await
3239    }
3240}
3241
3242/// A coalescing cache for the global tree snapshot (#1303).
3243///
3244/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
3245/// server re-samples each on its own `STREAM_TICK` and on every registry change
3246/// — so with N windows the *identical* global tree was being built N times per
3247/// tick. This cache collapses that to **one** build: all streams share it, and
3248/// it rebuilds at most once per `ttl` (the stream tick) per registry
3249/// change-generation.
3250///
3251/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
3252/// exactly as before:
3253/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
3254///   still matches — a `register`/`unregister`/toggle bumps it and forces a
3255///   fresh build, so subscribers never see a stale visible set; and
3256/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
3257///   branch switch, new commits), which fires no registry event, still surfaces
3258///   within one tick.
3259///
3260/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
3261/// callers, so a burst of N streams waking on the same tick/change performs one
3262/// build while the rest wait and read the shared result. The one-shot `tree` op
3263/// bypasses this and computes fresh — it is a rare manual refresh, not part of
3264/// the per-tick fan-out.
3265struct TreeSnapshotCache {
3266    /// The registry every snapshot is built from, and whose change-generation
3267    /// gates cache reuse.
3268    registry: Arc<WorktreesRegistry>,
3269    /// PR badges folded onto each worktree as the snapshot is built (#1337).
3270    /// Written by the background poller; read here. A miss simply omits `pr`.
3271    pr_cache: Arc<PrStatusCache>,
3272    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
3273    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
3274    /// coalesced build runs at most once per tick; tests inject a shorter value.
3275    ttl: Duration,
3276    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
3277    /// because it is deliberately held across the `.await` of the git
3278    /// enumeration, so concurrent callers serialize onto one build rather than
3279    /// each computing their own.
3280    state: AsyncMutex<Option<CachedTree>>,
3281    /// How many times the tree was actually (re)built — so tests can assert the
3282    /// coalescing collapses an N-stream burst into one build. Cheap and always
3283    /// maintained; only read under `#[cfg(test)]`.
3284    computes: AtomicU64,
3285}
3286
3287/// One cached tree snapshot: the shared value plus the two freshness stamps
3288/// [`TreeSnapshotCache`] checks before reusing it.
3289struct CachedTree {
3290    /// The registry change-generation captured *before* the build, so a change
3291    /// racing the build advances the generation and the next read rebuilds
3292    /// (conservative: it may rebuild once needlessly, but never serves stale).
3293    generation: u64,
3294    /// When the value was built, for the `ttl` staleness check.
3295    computed_at: Instant,
3296    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
3297    /// subscriber by cloning the `Arc`'s inner value.
3298    value: Arc<Value>,
3299}
3300
3301impl TreeSnapshotCache {
3302    /// Creates a cache over `registry` with the default TTL — the server's
3303    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
3304    /// build runs at most once per tick.
3305    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3306        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3307    }
3308
3309    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
3310    /// long) freshness window without waiting a real tick.
3311    fn with_ttl(
3312        registry: Arc<WorktreesRegistry>,
3313        pr_cache: Arc<PrStatusCache>,
3314        ttl: Duration,
3315    ) -> Self {
3316        Self {
3317            registry,
3318            pr_cache,
3319            ttl,
3320            state: AsyncMutex::new(None),
3321            computes: AtomicU64::new(0),
3322        }
3323    }
3324
3325    /// The current tree snapshot, built at most once per `ttl` per registry
3326    /// change-generation and shared across all callers. See the type docs for
3327    /// the freshness and single-flight semantics.
3328    async fn snapshot(&self) -> Value {
3329        // Hold the lock across the whole check-and-build so concurrent callers
3330        // serialize onto one build (single-flight); reading the generation here
3331        // (before the build) means a change racing the build forces the *next*
3332        // read to rebuild rather than serving this now-stale value.
3333        let mut state = self.state.lock().await;
3334        let generation = self.registry.change_generation();
3335        // Reuse the cached value only while it matches the current generation
3336        // *and* is within the TTL; either failing forces a rebuild.
3337        let fresh = state.as_ref().and_then(|cached| {
3338            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3339                .then(|| Arc::clone(&cached.value))
3340        });
3341        let value = if let Some(value) = fresh {
3342            value
3343        } else {
3344            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3345            self.computes.fetch_add(1, Ordering::Relaxed);
3346            *state = Some(CachedTree {
3347                generation,
3348                computed_at: Instant::now(),
3349                value: Arc::clone(&value),
3350            });
3351            value
3352        };
3353        // Release the lock before the (deeper) clone of the shared value out.
3354        drop(state);
3355        (*value).clone()
3356    }
3357
3358    /// How many times the tree was actually built — the coalescing assertion in
3359    /// tests (N reads within one tick/generation should build once).
3360    #[cfg(test)]
3361    fn compute_count(&self) -> u64 {
3362        self.computes.load(Ordering::Relaxed)
3363    }
3364}
3365
3366/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
3367/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
3368/// (the seed folders to derive repos from, and the live windows to join on) and
3369/// a lock-free read of the toggle, then the git enumeration/enrichment off the
3370/// lock on a blocking thread inside [`tree_repos`].
3371async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3372    let folders = registry.open_folders();
3373    let windows = registry.list();
3374    let show_closed = registry.show_closed();
3375    let enabled_polling = registry.enabled_polling_repos();
3376    // The transient rebase (#1415) and push (#1443) cues, read here with the other
3377    // cheap registry locks so the git work below deals only in plain data.
3378    let in_flight = InFlight::read(registry);
3379    json!({
3380        "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3381        "show_closed": show_closed,
3382    })
3383}
3384
3385/// A short human name for a window: its repo, else its first folder's basename,
3386/// else a placeholder.
3387fn display_name(entry: &WindowEntry) -> String {
3388    if let Some(repo) = &entry.repo {
3389        return repo.clone();
3390    }
3391    if let Some(folder) = entry.folders.first() {
3392        return folder.file_name().map_or_else(
3393            || folder.display().to_string(),
3394            |n| n.to_string_lossy().into_owned(),
3395        );
3396    }
3397    "(no folder)".to_string()
3398}
3399
3400/// Separator between the repo name and branch for a normal working tree.
3401const REPO_SEP: char = '·';
3402/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
3403/// line is distinguishable at a glance from its parent repo's main checkout.
3404const WORKTREE_SEP: char = '⑂';
3405
3406/// The full tray item list for a window set: the "No open windows" placeholder
3407/// when empty, else one line per window via [`window_menu_items`]. Does the git
3408/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
3409/// background refresh task — and inline only as a cold-start fallback in `menu`.
3410fn menu_items_for(
3411    entries: &[WindowEntry],
3412    rate_limit: Option<&RateLimitSnapshot>,
3413) -> Vec<MenuItem> {
3414    let mut items = Vec::new();
3415    // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
3416    // above the windows, so an approaching exhaustion is visible in the tray before
3417    // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
3418    if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3419        if !label.is_empty() {
3420            items.push(MenuItem::Label(label));
3421            items.push(MenuItem::Separator);
3422        }
3423    }
3424    if entries.is_empty() {
3425        items.push(MenuItem::Label("No open windows".to_string()));
3426    } else {
3427        items.extend(window_menu_items(entries));
3428    }
3429    items
3430}
3431
3432/// Builds the tray items for a non-empty window list: **one clickable line per
3433/// window** whose label carries the live git state and whose click focuses that
3434/// window. A window with no workspace folder has nothing for `code` to open, so
3435/// it stays a non-clickable status line. The labels read each worktree from disk
3436/// (via [`window_label`]) — cheap for a realistic window count and consistent
3437/// with reap-on-read.
3438fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3439    entries
3440        .iter()
3441        .map(|entry| {
3442            let label = window_label(entry);
3443            if entry.folders.is_empty() {
3444                MenuItem::Label(label)
3445            } else {
3446                MenuItem::Action(MenuAction {
3447                    id: format!("focus:{}", entry.key),
3448                    label,
3449                    enabled: true,
3450                })
3451            }
3452        })
3453        .collect()
3454}
3455
3456/// The tray label for one window: the **main repository** name, then live branch
3457/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
3458/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
3459/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
3460/// that is not a repo falls back to its reported title.
3461fn window_label(entry: &WindowEntry) -> String {
3462    let status = entry
3463        .folders
3464        .first()
3465        .map(|folder| git_status(folder))
3466        .unwrap_or_default();
3467    // Prefer the git-derived main repo so a linked worktree names its parent
3468    // repository rather than its worktree-folder basename.
3469    let name = status
3470        .main_repo
3471        .clone()
3472        .unwrap_or_else(|| display_name(entry));
3473    if let Some(branch) = &status.branch {
3474        let sep = if status.is_worktree {
3475            WORKTREE_SEP
3476        } else {
3477            REPO_SEP
3478        };
3479        return match sync_indicator(status.ahead, status.behind) {
3480            Some(sync) => format!("{name} {sep} {branch} {sync}"),
3481            None => format!("{name} {sep} {branch}"),
3482        };
3483    }
3484    // No git branch (not a repo / detached): fall back to the reported title.
3485    match &entry.title {
3486        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3487        _ => name,
3488    }
3489}
3490
3491/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
3492/// has no upstream to compare against.
3493fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3494    match (ahead, behind) {
3495        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3496        _ => None,
3497    }
3498}
3499
3500/// Well-known absolute locations for the VS Code launcher, tried in order so a
3501/// daemon running under launchd (with a minimal `PATH`) still finds it.
3502const CODE_BINARY_CANDIDATES: &[&str] = &[
3503    "/usr/local/bin/code",
3504    "/opt/homebrew/bin/code",
3505    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3506    "/usr/bin/code",
3507];
3508
3509/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
3510/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
3511/// with the sessions service's tray "focus" action, which resolves a session to
3512/// its VS Code window folder and opens it through this same guarded launcher.
3513pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3514    focus_window_with(&resolve_code_binary(), folder)
3515}
3516
3517/// Spawns `program` on `folder` after validating the folder. Split out from
3518/// [`focus_window`] so the validation and spawn paths are testable with an
3519/// explicit launcher (no environment or installed-editor dependency).
3520///
3521/// Best-effort and non-blocking: the spawned child is reaped on a detached
3522/// thread so a long-lived daemon does not accumulate zombies one per focus.
3523fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3524    // The tray path passes an absolute workspace folder, but the socket `open`
3525    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
3526    // real check there, not just an assertion: requiring an absolute path also
3527    // rules out a `-`-leading path being parsed by `code` as a flag.
3528    if !folder.is_absolute() {
3529        bail!(
3530            "refusing to focus a non-absolute folder path: {}",
3531            folder.display()
3532        );
3533    }
3534    if !folder.is_dir() {
3535        bail!("worktree folder no longer exists: {}", folder.display());
3536    }
3537    // Detach the launcher's stdio so its output never interleaves into the
3538    // long-lived daemon's own stdout/stderr (or the test harness's).
3539    let child = Command::new(program)
3540        .arg(folder)
3541        .stdin(Stdio::null())
3542        .stdout(Stdio::null())
3543        .stderr(Stdio::null())
3544        .spawn()
3545        .with_context(|| {
3546            format!(
3547                "failed to launch `{}` to focus {}",
3548                program.display(),
3549                folder.display()
3550            )
3551        })?;
3552    // Reap the child without blocking so it never lingers as a zombie.
3553    std::thread::spawn(move || {
3554        let mut child = child;
3555        let _ = child.wait();
3556    });
3557    Ok(())
3558}
3559
3560/// Resolves the VS Code launcher from the real environment: the
3561/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
3562/// `code` on `PATH`. The pure resolution logic lives in
3563/// [`resolve_code_binary_from`] for testing.
3564fn resolve_code_binary() -> PathBuf {
3565    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3566}
3567
3568/// Pure launcher resolution: `env_override` wins; otherwise the first existing
3569/// `candidate`; otherwise bare `code`.
3570fn resolve_code_binary_from(
3571    env_override: Option<std::ffi::OsString>,
3572    candidates: &[&str],
3573) -> PathBuf {
3574    if let Some(path) = env_override {
3575        return PathBuf::from(path);
3576    }
3577    for candidate in candidates {
3578        let path = Path::new(candidate);
3579        if path.exists() {
3580            return path.to_path_buf();
3581        }
3582    }
3583    PathBuf::from("code")
3584}
3585
3586// --- Reposition op (#1407) ---------------------------------------------------
3587
3588/// The `reposition` op payload: move every target window onto the invoking
3589/// window's geometry.
3590///
3591/// Keyed by **window key**, not worktree path, because the subject is a *window*:
3592/// the geometry belongs to the OS window, and a path resolves to one only via the
3593/// registry. The CLI (which naturally speaks paths) maps them to keys itself
3594/// before sending.
3595#[derive(Debug, Clone, Deserialize)]
3596struct RepositionRequest {
3597    /// The invoking window's key. Supplies the frame; never moved itself.
3598    reference_key: String,
3599    /// The windows to move. May include `reference_key` — a multi-selection
3600    /// naturally contains the invoking window — which is reported and skipped.
3601    #[serde(default)]
3602    target_keys: Vec<String>,
3603    /// Resolve and report only, writing nothing (`worktrees reposition
3604    /// --dry-run`). The diagnostic surface for title matching.
3605    #[serde(default)]
3606    check: bool,
3607}
3608
3609/// Distils the live registration for `key` into what [`geometry`] matches on.
3610///
3611/// A key with no live window still yields a value, flagged `live: false`, so it
3612/// reports as a per-target `no-window` skip rather than vanishing from the batch —
3613/// a tree row can be a tick stale, and the user needs to see which of their
3614/// selection was ignored.
3615fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3616    entries.iter().find(|entry| entry.key == key).map_or_else(
3617        || geometry::RegisteredWindow {
3618            key: key.to_string(),
3619            live: false,
3620            title: None,
3621            pid: None,
3622        },
3623        |entry| geometry::RegisteredWindow {
3624            key: entry.key.clone(),
3625            live: true,
3626            title: entry.title.clone(),
3627            pid: entry.pid,
3628        },
3629    )
3630}
3631
3632/// Renders a [`geometry::RepositionReport`] as the op's reply.
3633///
3634/// `trusted` is a reply **field**, not an error, so the client can branch on the
3635/// missing-permission case as data — offering the user a link to the Accessibility
3636/// settings pane — rather than pattern-matching an error string.
3637fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3638    let mut reply = json!({
3639        "trusted": report.trusted,
3640        "results": report.results,
3641        "moved": report.moved(),
3642        "skipped": report.skipped(),
3643    });
3644    if let Some(reference) = &report.reference {
3645        reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3646    }
3647    if let Some(blocked) = &report.blocked {
3648        reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3649    }
3650    // Omitted unless true, so a client that only reads `results` sees a reply
3651    // byte-identical to one from a daemon without the undo store.
3652    if undoable {
3653        reply["undoable"] = Value::Bool(true);
3654    }
3655    reply
3656}
3657
3658/// Emits the audit line for a `reposition`, so `omni-dev daemon logs` can answer
3659/// "why did that window not move?" from the log alone (the ADR-0049 §6 precedent).
3660/// Sync, like [`log_merge_check`].
3661fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3662    // `phase` is a structured field rather than three message literals, so a log
3663    // filter can select checks from applies without matching on prose.
3664    let phase = if !report.trusted {
3665        "untrusted"
3666    } else if report.blocked.is_some() {
3667        "blocked"
3668    } else if req.check {
3669        "check"
3670    } else {
3671        "apply"
3672    };
3673    tracing::info!(
3674        phase,
3675        reference = req.reference_key.as_str(),
3676        requested = req.target_keys.len(),
3677        blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3678        moved = report.moved(),
3679        skipped = report.skipped(),
3680        outcomes = outcome_kinds(report).as_str(),
3681        "reposition"
3682    );
3683}
3684
3685/// Emits the audit line for a `reposition-undo`.
3686fn log_reposition_undo(report: &geometry::RepositionReport) {
3687    tracing::info!(
3688        trusted = report.trusted,
3689        restored = report.moved(),
3690        skipped = report.skipped(),
3691        outcomes = outcome_kinds(report).as_str(),
3692        "reposition undo"
3693    );
3694}
3695
3696/// Joins a report's per-target outcome slugs into one compact `a,b,b` field, so a
3697/// batch's verdict rides a single structured log value rather than a `Debug` dump —
3698/// the [`note_kinds`] precedent.
3699fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3700    if report.results.is_empty() {
3701        return "-".to_string();
3702    }
3703    report
3704        .results
3705        .iter()
3706        .map(|r| r.outcome)
3707        .collect::<Vec<_>>()
3708        .join(",")
3709}
3710
3711// --- Reload op (#1417) -------------------------------------------------------
3712
3713/// The `reload` op payload: reload the listed windows.
3714///
3715/// Keyed by **window**, like [`RepositionRequest`] and unlike [`CloseRequest`] —
3716/// a reload acts on a window, and one tree row is one window, whereas a path can
3717/// be open in several. There is no `requester_key`: a client that wants to
3718/// reload itself does so directly rather than waiting a heartbeat for its own
3719/// directive, so the daemon never needs to know who asked.
3720#[derive(Debug, Clone, Deserialize)]
3721struct ReloadRequest {
3722    /// Registry keys of the windows to signal. An empty list is a no-op, not an
3723    /// error: the callers all filter their targets first, and reporting zeros is
3724    /// more useful to a batch client than a failure.
3725    #[serde(default)]
3726    target_keys: Vec<String>,
3727}
3728
3729/// Emits the audit line for a `reload` op. Sync, like the `close` loggers, so it
3730/// is unit-testable off the runtime. Logs counts and the unknown keys only —
3731/// never a path, which this op never sees.
3732fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3733    // Formatted before the macro, not inside it: a `tracing` field expression is
3734    // only evaluated when a subscriber is interested, so inlining this would
3735    // leave it unexecuted (and unmeasurable) in any test that installs none.
3736    let unknown = if unknown.is_empty() {
3737        "-".to_string()
3738    } else {
3739        unknown.join(",")
3740    };
3741    tracing::info!(
3742        requested,
3743        signalled,
3744        unknown = %unknown,
3745        "worktrees reload: signalled windows"
3746    );
3747}
3748
3749// --- Close op (#1277) --------------------------------------------------------
3750
3751/// The `close` op payload: close a worktree's window and (for a linked worktree)
3752/// delete it. Symmetric to `open`, but destructive, so it carries the
3753/// two-phase-confirm and self-close routing fields.
3754#[derive(Debug, Clone, Deserialize)]
3755struct CloseRequest {
3756    /// Absolute path of the target worktree's working directory.
3757    path: PathBuf,
3758    /// The requesting window's key, so a self-close (`requester_key` owns the
3759    /// target) removes-then-replies and lets the extension close its own window,
3760    /// rather than waiting on a window that is blocked awaiting this reply.
3761    #[serde(default)]
3762    requester_key: Option<String>,
3763    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
3764    /// only close its window (main "Close Window"). A delete is refused on the
3765    /// main working tree regardless of this flag.
3766    #[serde(default)]
3767    remove: bool,
3768    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
3769    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
3770    #[serde(default)]
3771    confirmed: bool,
3772}
3773
3774/// One risk or informational note in a [`SafetyReport`]: a machine-readable
3775/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
3776/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
3777/// commits that survive because the branch is kept).
3778#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3779struct Note {
3780    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
3781    kind: String,
3782    /// A human-readable one-line explanation for the confirm dialog.
3783    detail: String,
3784}
3785
3786impl Note {
3787    fn new(kind: &str, detail: impl Into<String>) -> Self {
3788        Self {
3789            kind: kind.to_string(),
3790            detail: detail.into(),
3791        }
3792    }
3793}
3794
3795/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
3796/// `-`) for a single structured log field. Used by the `close` op's audit lines
3797/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
3798fn note_kinds(notes: &[Note]) -> String {
3799    if notes.is_empty() {
3800        return "-".to_string();
3801    }
3802    notes
3803        .iter()
3804        .map(|n| n.kind.as_str())
3805        .collect::<Vec<_>>()
3806        .join(",")
3807}
3808
3809/// Whether a `close` execute is a **self-close**: the requesting window owns the
3810/// target, so it acts on our `ok:true` reply and never rides the cross-window
3811/// signal. Split out as a pure predicate so the routing decision the audit line
3812/// (#1364) reports is unit-testable.
3813fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3814    requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3815}
3816
3817/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
3818/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
3819/// this a failed safety check (a non-git-worktree target) or a panicked blocking
3820/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
3821/// the error unchanged so callers keep using `?`.
3822fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3823    tracing::error!(
3824        path = %path.display(),
3825        "worktrees close: {phase} failed: {err:#}"
3826    );
3827    err
3828}
3829
3830/// Logs the outcome of a linked-worktree removal and maps it to the `close`
3831/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
3832/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
3833/// the real prune runs behind.
3834///
3835/// The three outcomes are logged distinctly (#1403) so a future "the daemon says
3836/// pruned but the row is still there" is diagnosable from the log alone: an
3837/// actual prune and an already-gone no-op are both INFO (and both reply
3838/// `removed: true` — the row should go either way), but carry different
3839/// `outcome`/message text; a failure is WARN and propagates the error.
3840fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3841    match removed {
3842        Ok(Removal::Pruned) => {
3843            tracing::info!(
3844                path = %path.display(),
3845                outcome = "pruned",
3846                "worktrees close: linked worktree pruned"
3847            );
3848            Ok(json!({ "removed": true }))
3849        }
3850        Ok(Removal::AlreadyGone) => {
3851            tracing::info!(
3852                path = %path.display(),
3853                outcome = "already-gone",
3854                "worktrees close: nothing to prune, worktree already removed"
3855            );
3856            Ok(json!({ "removed": true }))
3857        }
3858        Err(err) => {
3859            tracing::warn!(
3860                path = %path.display(),
3861                outcome = "failed",
3862                "worktrees close: worktree prune failed: {err:#}"
3863            );
3864            Err(err)
3865        }
3866    }
3867}
3868
3869/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
3870/// the owning window key (if any), the open flag, and the deletability verdict
3871/// with the blocking risk kinds. Split out so the audit line is unit-testable off
3872/// the runtime — a `tracing` event fired right after the `git_safety`
3873/// `spawn_blocking` is not reliably captured under the parallel suite.
3874fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
3875    tracing::info!(
3876        path = %path.display(),
3877        window_key = window_key.unwrap_or("-"),
3878        removable = git.removable,
3879        is_main = git.is_main,
3880        open,
3881        risks = %note_kinds(&git.risks),
3882        "worktrees close: safety check"
3883    );
3884}
3885
3886/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
3887/// window key and the routing decision (self-close vs. how many cross-window
3888/// targets are being signalled), logged before the wait so it is auditable even
3889/// if that wait then hangs. Sync so it is unit-testable off the runtime.
3890fn log_executing(
3891    path: &Path,
3892    requester: Option<&str>,
3893    remove: bool,
3894    self_close: bool,
3895    cross_window: usize,
3896) {
3897    tracing::info!(
3898        path = %path.display(),
3899        requester = requester.unwrap_or("-"),
3900        remove,
3901        self_close,
3902        cross_window,
3903        "worktrees close: executing"
3904    );
3905}
3906
3907/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
3908/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
3909/// unit-testable off the runtime.
3910fn log_close_abort(path: &Path, err: &anyhow::Error) {
3911    tracing::warn!(
3912        path = %path.display(),
3913        "worktrees close: aborted — signalled window(s) did not close: {err:#}"
3914    );
3915}
3916
3917/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
3918/// the window is closed and nothing is deleted (#1364). Sync so it is
3919/// unit-testable off the runtime.
3920fn log_window_closed(path: &Path) {
3921    tracing::info!(
3922        path = %path.display(),
3923        "worktrees close: window closed, no removal"
3924    );
3925}
3926
3927/// The phase-1 safety report the extension reads to decide whether to prompt.
3928/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
3929/// entry → show a modal confirm listing them.
3930#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3931struct SafetyReport {
3932    /// Whether the target is a deletable (linked) worktree at all — `false` for
3933    /// the main working tree, which the daemon never removes.
3934    removable: bool,
3935    /// Whether the target is the repository's main working tree.
3936    is_main: bool,
3937    /// Whether a live VS Code window currently has the target open.
3938    open: bool,
3939    /// The owning window's key, when `open` (the first, for the wait/close).
3940    #[serde(skip_serializing_if = "Option::is_none")]
3941    window_key: Option<String>,
3942    /// How many workspace folders the owning window has — so the extension can
3943    /// warn "this window has N folders open; all will close" (failure mode #10).
3944    window_folder_count: usize,
3945    /// Conditions that would lose data on removal; a non-empty list forces a
3946    /// confirm dialog.
3947    risks: Vec<Note>,
3948    /// Non-blocking context shown for awareness (e.g. unpushed commits that
3949    /// survive because the branch is kept).
3950    info: Vec<Note>,
3951}
3952
3953/// The git-only half of the safety check, before the registry's open-window
3954/// facts are folded in. Pure disk I/O; computed on a blocking thread.
3955#[derive(Debug, Clone, PartialEq, Eq)]
3956struct GitSafety {
3957    is_main: bool,
3958    removable: bool,
3959    risks: Vec<Note>,
3960    info: Vec<Note>,
3961}
3962
3963// --- Rebase op (#1415) -------------------------------------------------------
3964
3965/// The `rebase` op payload: batch-rebase worktrees onto their repository's remote
3966/// default branch. Two-phase like [`MergeQueueRequest`], keyed off `confirmed`,
3967/// and likewise a **single batched** op over `paths` — which is what buys the
3968/// fetch-once-per-repository contract (ADR-0055 §2), since the engine can only
3969/// group by repository if it sees the whole selection at once.
3970#[derive(Debug, Clone, Deserialize)]
3971struct RebaseRequest {
3972    /// Absolute paths of the selected worktree folders.
3973    paths: Vec<PathBuf>,
3974    /// The requesting window's key — carried for the audit line, as `close` and
3975    /// `merge-queue` carry theirs.
3976    #[serde(default)]
3977    requester_key: Option<String>,
3978    /// Phase 1: plan and report only, never rebase.
3979    #[serde(default)]
3980    check: bool,
3981    /// Phase 2: rebase the (re-validated) pending worktrees.
3982    #[serde(default)]
3983    confirmed: bool,
3984    /// Leave a conflicting worktree mid-rebase instead of aborting it. The tree
3985    /// view sends `true` — resolving a conflict in place is the point of #1415 —
3986    /// but it stays a client choice, and defaults to the engine's conservative
3987    /// abort so an older or scripted client gets the pre-#1415 behaviour.
3988    #[serde(default)]
3989    keep_conflicts: bool,
3990    /// Stash uncommitted changes around each rebase rather than skipping a dirty
3991    /// worktree. Not surfaced by the tree view; here so a socket client can ask.
3992    #[serde(default)]
3993    autostash: bool,
3994    /// Rebase onto this ref instead of the remote default branch.
3995    #[serde(default)]
3996    onto: Option<String>,
3997}
3998
3999impl RebaseRequest {
4000    /// The engine options this request selects, with `git` already resolved.
4001    fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4002        worktree_rebase::RebaseOptions {
4003            onto: self.onto.clone(),
4004            autostash: self.autostash,
4005            // The daemon never uses the engine's own dry-run flag: phase 1 *is*
4006            // the dry run, and it is `plan` (never `execute`) that runs for it.
4007            dry_run: false,
4008            keep_conflicts: self.keep_conflicts,
4009            git_bin: Some(git_bin),
4010        }
4011    }
4012}
4013
4014/// Runs [`worktree_rebase::plan`] on a blocking thread: it shells out to
4015/// `git fetch` once per repository and walks each worktree's object database, so
4016/// it must never run on an async worker.
4017async fn plan_rebase(
4018    selection: &Selection,
4019    opts: &worktree_rebase::RebaseOptions,
4020) -> Result<worktree_rebase::Plan> {
4021    let selection = selection.clone();
4022    let opts = opts.clone();
4023    tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4024        .await
4025        .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4026        .and_then(|inner| inner)
4027}
4028
4029/// Builds a `rebase` reply. Both phases share one shape — the per-repo fetch
4030/// outcomes and the per-worktree results — because phase 1's report and phase 2's
4031/// result differ only in which [`RebaseResult`](worktree_rebase::RebaseResult)
4032/// variants appear, and a client that can render one can render the other.
4033fn rebase_reply(
4034    fetches: &[worktree_rebase::FetchOutcome],
4035    worktrees: &[worktree_rebase::WorktreeOutcome],
4036) -> Value {
4037    json!({ "fetches": fetches, "worktrees": worktrees })
4038}
4039
4040/// Emits the phase-1 audit line for a `rebase` plan (ADR-0049 §6's precedent, as
4041/// applied by [`log_merge_check`]): who asked, how many worktrees were named, and
4042/// how many the classifier found actually pending.
4043fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4044    let pending = plan
4045        .worktrees
4046        .iter()
4047        .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4048        .count();
4049    let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4050    tracing::info!(
4051        requester = req.requester_key.as_deref().unwrap_or("-"),
4052        requested = req.paths.len(),
4053        pending,
4054        fetches = plan.fetches.len(),
4055        failed_fetches,
4056        "rebase check"
4057    );
4058}
4059
4060/// Emits the phase-2 audit line for a `rebase` execute: the history-rewriting
4061/// outcome, counted by kind. A left-in-place conflict is counted separately from
4062/// an aborted one — it is the case that leaves a worktree needing the user.
4063fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4064    use worktree_rebase::RebaseResult;
4065    let mut rebased = 0;
4066    let mut conflicts = 0;
4067    let mut left_in_place = 0;
4068    let mut skipped = 0;
4069    for outcome in outcomes {
4070        match &outcome.result {
4071            RebaseResult::Rebased { .. } => rebased += 1,
4072            RebaseResult::Conflict {
4073                left_in_place: k, ..
4074            } => {
4075                conflicts += 1;
4076                if *k {
4077                    left_in_place += 1;
4078                }
4079            }
4080            RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4081            RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4082        }
4083    }
4084    tracing::info!(
4085        requester = req.requester_key.as_deref().unwrap_or("-"),
4086        requested = req.paths.len(),
4087        rebased,
4088        conflicts,
4089        left_in_place,
4090        skipped,
4091        "rebase execute"
4092    );
4093}
4094
4095// --- Push op (#1443) ---------------------------------------------------------
4096
4097/// The `push` op payload: publish worktrees' branches to their upstreams,
4098/// force-pushing with a lease where history was rewritten. Two-phase like
4099/// [`RebaseRequest`], keyed off `confirmed`, and likewise a **single batched** op
4100/// over `paths` so the reply is one per-worktree summary rather than N independent
4101/// results.
4102///
4103/// Deliberately has **no** force knob. There is no field a client can set to escape
4104/// the lease, and none to reach a remote other than the branch's own upstream —
4105/// both by design (ADR-0061 §2).
4106#[derive(Debug, Clone, Deserialize)]
4107struct PushRequest {
4108    /// Absolute paths of the selected worktree folders.
4109    paths: Vec<PathBuf>,
4110    /// The requesting window's key — carried for the audit line, as `close`,
4111    /// `merge-queue` and `rebase` carry theirs.
4112    #[serde(default)]
4113    requester_key: Option<String>,
4114    /// Phase 1: classify and report only, never push.
4115    #[serde(default)]
4116    check: bool,
4117    /// Phase 2: publish the (re-validated) pending worktrees.
4118    #[serde(default)]
4119    confirmed: bool,
4120}
4121
4122/// Runs [`worktree_push::plan`] on a blocking thread: it walks each worktree's
4123/// object database, so it must never run on an async worker. Unlike
4124/// [`plan_rebase`] it needs no `git` binary — planning a push contacts no remote.
4125async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4126    let selection = selection.clone();
4127    tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4128        .await
4129        .map_err(|e| anyhow!("push planning task panicked: {e}"))
4130        .and_then(|inner| inner)
4131}
4132
4133/// Builds a `push` reply. Both phases share one shape — the per-worktree results —
4134/// because phase 1's report and phase 2's result differ only in which
4135/// [`PushResult`](worktree_push::PushResult) variants appear, and a client that can
4136/// render one can render the other.
4137///
4138/// There is no `fetches` field (the one shape difference from `rebase`): a push
4139/// plan contacts no remote, so there is nothing per-repository to report.
4140fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4141    json!({ "worktrees": worktrees })
4142}
4143
4144/// Emits the phase-1 audit line for a `push` plan: who asked, how many worktrees
4145/// were named, how many are pending, and — separately — how many would need the
4146/// lease, since that is the interesting half.
4147fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4148    use worktree_push::PushResult;
4149    let pending = plan
4150        .worktrees
4151        .iter()
4152        .filter(|w| w.result.is_pending())
4153        .count();
4154    let forced = plan
4155        .worktrees
4156        .iter()
4157        .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4158        .count();
4159    let skipped = plan
4160        .worktrees
4161        .iter()
4162        .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4163        .count();
4164    tracing::info!(
4165        requester = req.requester_key.as_deref().unwrap_or("-"),
4166        requested = req.paths.len(),
4167        pending,
4168        forced,
4169        skipped,
4170        "push check"
4171    );
4172}
4173
4174/// Emits the phase-2 audit line for a `push` execute. `forced` and `stale_rejected`
4175/// are broken out deliberately: the first is the count of histories this daemon
4176/// published a rewrite of, and the second the count of times the lease stopped it
4177/// from overwriting work it had not seen.
4178fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4179    use worktree_push::PushResult;
4180    let mut pushed = 0;
4181    let mut forced = 0;
4182    let mut created = 0;
4183    let mut rejected = 0;
4184    let mut stale_rejected = 0;
4185    for outcome in outcomes {
4186        match &outcome.result {
4187            PushResult::Pushed { forced: f } => {
4188                pushed += 1;
4189                if *f {
4190                    forced += 1;
4191                }
4192            }
4193            PushResult::Created => created += 1,
4194            PushResult::Rejected { stale, .. } => {
4195                rejected += 1;
4196                if *stale {
4197                    stale_rejected += 1;
4198                }
4199            }
4200            PushResult::UpToDate
4201            | PushResult::WouldFastForward { .. }
4202            | PushResult::WouldForce { .. }
4203            | PushResult::WouldCreate
4204            | PushResult::Skipped { .. } => {}
4205        }
4206    }
4207    tracing::info!(
4208        requester = req.requester_key.as_deref().unwrap_or("-"),
4209        requested = req.paths.len(),
4210        pushed,
4211        forced,
4212        created,
4213        rejected,
4214        stale_rejected,
4215        "push execute"
4216    );
4217}
4218
4219// --- Merge-queue op (#1401) --------------------------------------------------
4220
4221/// The `merge-queue` op payload: batch-enqueue eligible worktrees' PRs into the
4222/// GitHub merge queue. Two-phase like [`CloseRequest`], keyed off `confirmed`, but
4223/// a **single batched** op over `paths` rather than one op per target.
4224#[derive(Debug, Clone, Deserialize)]
4225struct MergeQueueRequest {
4226    /// Absolute paths of the selected worktree folders.
4227    paths: Vec<PathBuf>,
4228    /// The requesting window's key — carried for parity with `close` and future
4229    /// per-window routing; unused today.
4230    #[serde(default)]
4231    requester_key: Option<String>,
4232    /// Phase 1: report eligibility only, never enqueue.
4233    #[serde(default)]
4234    check: bool,
4235    /// Phase 2: enqueue the (re-validated) eligible PRs.
4236    #[serde(default)]
4237    confirmed: bool,
4238}
4239
4240/// One enqueue-eligible worktree in an [`EligibilityReport`].
4241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4242struct PrRef {
4243    /// The worktree folder.
4244    path: String,
4245    /// The open PR number.
4246    number: u64,
4247    /// The PR's web URL.
4248    url: String,
4249    /// The branch the PR heads.
4250    branch: String,
4251}
4252
4253impl From<&Eligible> for PrRef {
4254    fn from(e: &Eligible) -> Self {
4255        Self {
4256            path: e.path.to_string_lossy().to_string(),
4257            number: e.number,
4258            url: e.url.clone(),
4259            branch: e.branch.clone(),
4260        }
4261    }
4262}
4263
4264/// One skipped worktree: which, and why — a machine `kind` slug plus a
4265/// human-readable `detail`, mirroring [`Note`].
4266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4267struct Skip {
4268    path: String,
4269    kind: String,
4270    detail: String,
4271}
4272
4273impl Skip {
4274    fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4275        Self {
4276            path: path.to_string_lossy().to_string(),
4277            kind: kind.to_string(),
4278            detail: detail.into(),
4279        }
4280    }
4281}
4282
4283/// The phase-1 reply: which selected worktrees are enqueue-eligible and which are
4284/// skipped-with-reason. The extension confirms once over the whole set, then sends
4285/// the phase-2 execute.
4286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4287struct EligibilityReport {
4288    eligible: Vec<PrRef>,
4289    skipped: Vec<Skip>,
4290}
4291
4292/// One PR successfully in the queue after phase 2.
4293#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4294struct QueuedPr {
4295    path: String,
4296    number: u64,
4297    /// True when the PR was already in the queue — an idempotent no-op, reported as
4298    /// success. Omitted (false) on the wire for the common freshly-queued case.
4299    #[serde(skip_serializing_if = "is_false")]
4300    already_queued: bool,
4301}
4302
4303/// One PR the enqueue mutation rejected (merge queue disabled, not mergeable,
4304/// insufficient permissions, …).
4305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4306struct EnqueueFailure {
4307    path: String,
4308    number: u64,
4309    error: String,
4310}
4311
4312/// The phase-2 reply: the enqueue outcome for the selected worktrees. `skipped` is
4313/// the re-validated skip set (a worktree that became ineligible between phases).
4314#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4315struct EnqueueResult {
4316    queued: Vec<QueuedPr>,
4317    skipped: Vec<Skip>,
4318    failed: Vec<EnqueueFailure>,
4319}
4320
4321/// A worktree that cleared the local (git-only) gates 1–3, carrying what the
4322/// network step needs to resolve its PR.
4323#[derive(Debug)]
4324struct LocalOk {
4325    path: PathBuf,
4326    target: PrTarget,
4327    head_sha: String,
4328}
4329
4330/// A worktree that cleared **every** gate and is ready to enqueue.
4331#[derive(Debug)]
4332struct Eligible {
4333    path: PathBuf,
4334    number: u64,
4335    url: String,
4336    branch: String,
4337    /// The PR's GraphQL node id — the `enqueuePullRequest` input.
4338    pr_id: String,
4339    /// Already in the queue ⇒ phase 2 skips the mutation and reports success.
4340    already_queued: bool,
4341}
4342
4343/// Evaluates the **local** (git-only) merge-queue gates for one worktree — clean
4344/// tree (1), a real commit (2), fully pushed (3) — and resolves the branch's
4345/// [`PrTarget`] for the network step. Pure disk I/O; runs on a blocking thread.
4346/// Returns the first failing gate as a [`Skip`] so an ineligible worktree never
4347/// costs a GitHub call.
4348fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4349    let Ok(repo) = Repository::discover(path) else {
4350        return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4351    };
4352    // Gate 1: a clean working tree (reusing the `close` safety check's counter).
4353    let (dirty, untracked) = count_dirty_untracked(&repo);
4354    if dirty > 0 {
4355        return Err(Skip::new(
4356            path,
4357            "dirty",
4358            format!("{dirty} modified tracked file(s) — commit or stash first"),
4359        ));
4360    }
4361    if untracked > 0 {
4362        return Err(Skip::new(
4363            path,
4364            "untracked",
4365            format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4366        ));
4367    }
4368    // Gate 2: a real commit exists (a non-unborn HEAD). The deeper "commits beyond
4369    // base" is proven by the open PR (gate 4) + GitHub's own enqueue validation.
4370    let Ok(head) = repo.head() else {
4371        return Err(Skip::new(
4372            path,
4373            "no-commits",
4374            "the branch has no commits yet",
4375        ));
4376    };
4377    let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4378        return Err(Skip::new(
4379            path,
4380            "no-commits",
4381            "HEAD does not resolve to a commit",
4382        ));
4383    };
4384    // A branch HEAD has a UTF-8 shorthand; a detached HEAD has no branch — and so
4385    // no branch PR to enqueue. Read before `Branch::wrap` consumes `head`.
4386    let Some(branch_name) = head
4387        .shorthand()
4388        .ok()
4389        .filter(|_| head.is_branch())
4390        .map(str::to_string)
4391    else {
4392        return Err(Skip::new(
4393            path,
4394            "detached",
4395            "HEAD is detached — no branch to enqueue",
4396        ));
4397    };
4398    let branch = git2::Branch::wrap(head);
4399    // Gate 3: fully pushed — an upstream exists and matches the local head.
4400    let Some(upstream_sha) = upstream_target(&branch) else {
4401        return Err(Skip::new(
4402            path,
4403            "no-upstream",
4404            "the branch tracks no upstream — push it first",
4405        ));
4406    };
4407    if upstream_sha != head_sha {
4408        return Err(Skip::new(
4409            path,
4410            "unpushed",
4411            "local commits are not on the remote yet — push first",
4412        ));
4413    }
4414    // Belt-and-suspenders: even with matching heads, a positive ahead count is
4415    // unpushed work.
4416    if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4417        if ahead > 0 {
4418            return Err(Skip::new(
4419                path,
4420                "unpushed",
4421                format!("{ahead} unpushed commit(s) — push first"),
4422            ));
4423        }
4424    }
4425    // Gate 4 setup: the branch's GitHub identity, so the network step can resolve
4426    // its PR. A non-github repo can never have a merge-queue PR.
4427    let Some(id) = remote_github_identity(&repo) else {
4428        return Err(Skip::new(
4429            path,
4430            "no-github",
4431            "the repository has no github.com remote",
4432        ));
4433    };
4434    Ok(LocalOk {
4435        path: path.to_path_buf(),
4436        target: PrTarget {
4437            owner: id.owner,
4438            name: id.name,
4439            branch: branch_name,
4440        },
4441        head_sha,
4442    })
4443}
4444
4445/// Whether GitHub's `mergeStateStatus` says the PR cannot merge cleanly. `DIRTY`
4446/// (merge conflicts) and the explicit `CONFLICTING` both block enqueue; other
4447/// states (`BLOCKED` on a required review, `UNKNOWN` still computing, `CLEAN`) do
4448/// not, since the merge queue itself resolves them.
4449fn is_conflicting(state: Option<&str>) -> bool {
4450    matches!(state, Some("CONFLICTING" | "DIRTY"))
4451}
4452
4453/// A human-readable label for a rolled-up CI verdict, for a `checks-failing` skip.
4454fn check_label(state: PrCheckState) -> &'static str {
4455    match state {
4456        PrCheckState::Success => "passing",
4457        PrCheckState::Failure => "failing",
4458        PrCheckState::Pending => "still running",
4459        PrCheckState::None => "not reported",
4460    }
4461}
4462
4463/// Emits the phase-1 audit line for a `merge-queue` check (ADR-0056; the ADR-0049
4464/// §6 precedent): the requesting window key, how many worktrees were requested,
4465/// and the eligible/skipped split. Sync so it is unit-testable off the runtime —
4466/// and so its `tracing` field expressions are exercised under an INFO subscriber.
4467fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4468    tracing::info!(
4469        requester = req.requester_key.as_deref().unwrap_or("-"),
4470        requested = req.paths.len(),
4471        eligible,
4472        skipped,
4473        "merge-queue check"
4474    );
4475}
4476
4477/// Emits the phase-2 audit line for a `merge-queue` enqueue: the requesting window
4478/// key and the queued/failed/skipped counts. Sync, for the same reasons as
4479/// [`log_merge_check`].
4480fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4481    tracing::info!(
4482        requester = req.requester_key.as_deref().unwrap_or("-"),
4483        queued,
4484        failed,
4485        skipped,
4486        "merge-queue enqueue"
4487    );
4488}
4489
4490/// Evaluates every merge-queue gate for a batch of worktree paths and partitions
4491/// them into the enqueue-eligible and the skipped-with-reason. **Blocking** — run
4492/// on a blocking thread.
4493///
4494/// Local gates 1–3 run first (per path); only survivors reach GitHub, so a dirty
4495/// or unpushed worktree is skipped with **zero** API calls. The survivors' PRs are
4496/// resolved in **one** batched `gh api graphql` call, then the network gates —
4497/// an open PR (4), not a draft (5), not conflicting (6), CI green (7), and the
4498/// remote head matching the local head — are applied. Shared by both phases: phase
4499/// 2 re-runs it (never trusting a phase-1 result the client sent).
4500fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4501    let mut skipped = Vec::new();
4502    let mut locals = Vec::new();
4503    for path in paths {
4504        match evaluate_local(path) {
4505            Ok(ok) => locals.push(ok),
4506            Err(skip) => skipped.push(skip),
4507        }
4508    }
4509    if locals.is_empty() {
4510        return Ok((Vec::new(), skipped));
4511    }
4512    let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4513    let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4514    let mut eligible = Vec::new();
4515    for local in locals {
4516        let Some(info) = resolved.get(&local.target) else {
4517            skipped.push(Skip::new(
4518                &local.path,
4519                "no-pr",
4520                "no open PR heads this branch",
4521            ));
4522            continue;
4523        };
4524        if info.head_oid != local.head_sha {
4525            skipped.push(Skip::new(
4526                &local.path,
4527                "stale",
4528                "the open PR's head differs from the local head — re-check",
4529            ));
4530        } else if info.is_draft {
4531            skipped.push(Skip::new(
4532                &local.path,
4533                "draft",
4534                format!("PR #{} is a draft", info.number),
4535            ));
4536        } else if is_conflicting(info.merge_state.as_deref()) {
4537            skipped.push(Skip::new(
4538                &local.path,
4539                "conflicting",
4540                format!("PR #{} has merge conflicts", info.number),
4541            ));
4542        } else if info.checks != PrCheckState::Success {
4543            skipped.push(Skip::new(
4544                &local.path,
4545                "checks-failing",
4546                format!(
4547                    "PR #{} checks are {}",
4548                    info.number,
4549                    check_label(info.checks)
4550                ),
4551            ));
4552        } else {
4553            eligible.push(Eligible {
4554                path: local.path,
4555                number: info.number,
4556                url: info.url.clone(),
4557                branch: local.target.branch.clone(),
4558                pr_id: info.pr_id.clone(),
4559                already_queued: info.already_queued,
4560            });
4561        }
4562    }
4563    Ok((eligible, skipped))
4564}
4565
4566/// Enqueues each eligible PR into its repo's merge queue, sequentially.
4567/// **Blocking** — run on a blocking thread. An already-queued PR is reported as
4568/// success without a mutation; a GitHub rejection or a failed `gh` invocation
4569/// lands in `failed[]`, so one un-enqueuable PR never sinks the batch.
4570fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4571    let mut queued = Vec::new();
4572    let mut failed = Vec::new();
4573    for e in eligible {
4574        let path = e.path.to_string_lossy().to_string();
4575        if e.already_queued {
4576            queued.push(QueuedPr {
4577                path,
4578                number: e.number,
4579                already_queued: true,
4580            });
4581            continue;
4582        }
4583        match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4584            Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4585                path,
4586                number: e.number,
4587                already_queued: false,
4588            }),
4589            Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4590                path,
4591                number: e.number,
4592                error: msg,
4593            }),
4594            Err(err) => failed.push(EnqueueFailure {
4595                path,
4596                number: e.number,
4597                error: format!("{err:#}"),
4598            }),
4599        }
4600    }
4601    (queued, failed)
4602}
4603
4604/// Live windows (key, workspace-folder count) that currently have `path` open,
4605/// matched by canonicalized path so a symlinked or `..`-laden report still
4606/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
4607fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4608    let target = canonical(path);
4609    entries
4610        .iter()
4611        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4612        .map(|e| (e.key.clone(), e.folders.len()))
4613        .collect()
4614}
4615
4616/// How long the execute phase waits for a signalled window to close
4617/// (`unregister`) before giving up. Deliberately generous against the ~10s
4618/// heartbeat interval the close directive rides — a window may have just
4619/// heartbeated, so the directive is only picked up on the *next* one — plus the
4620/// window's own close/save latency. The keyed-push responsiveness upgrade
4621/// (#1277 fast-follow) removes this wait entirely.
4622const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4623
4624/// How often the execute phase re-checks whether the signalled windows have
4625/// unregistered.
4626const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4627
4628/// Waits up to `timeout` for every window *other than* `requester` that has
4629/// `path` open to unregister (close), polling the live registry every `poll`.
4630/// A window whose `last_seen` has already gone stale is reaped by `list()` and
4631/// so counts as closed. Returns an error naming the still-open windows on
4632/// timeout, so the caller can surface "window did not close" and leave the
4633/// worktree untouched (failure modes #4/#5).
4634async fn await_windows_closed(
4635    registry: &WorktreesRegistry,
4636    path: &Path,
4637    requester: Option<&str>,
4638    timeout: Duration,
4639    poll: Duration,
4640) -> Result<()> {
4641    let deadline = std::time::Instant::now() + timeout;
4642    loop {
4643        // The registry read is cheap CPU, but the path canonicalization in
4644        // `windows_with_path` is disk I/O — do the whole check on a blocking
4645        // thread, never on the async worker.
4646        let entries = registry.list();
4647        let path = path.to_path_buf();
4648        let requester = requester.map(str::to_string);
4649        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4650            windows_with_path(&entries, &path)
4651                .into_iter()
4652                .map(|(k, _)| k)
4653                .filter(|k| requester.as_deref() != Some(k))
4654                .collect()
4655        })
4656        .await
4657        .unwrap_or_default();
4658
4659        if remaining.is_empty() {
4660            return Ok(());
4661        }
4662        if std::time::Instant::now() >= deadline {
4663            bail!("window(s) did not close in time: {}", remaining.join(", "));
4664        }
4665        tokio::time::sleep(poll).await;
4666    }
4667}
4668
4669/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
4670/// working tree (never removable) and, for a linked worktree, what a removal
4671/// would lose. Best-effort per-check but the overall open must succeed — a path
4672/// that is not a git worktree is a hard error (we refuse to delete an unknown
4673/// directory). A path that no longer exists is treated as an already-removed
4674/// linked worktree so the idempotent execute path can proceed with no dialog.
4675fn git_safety(path: &Path) -> Result<GitSafety> {
4676    if !path.exists() {
4677        return Ok(GitSafety {
4678            is_main: false,
4679            removable: true,
4680            risks: vec![],
4681            info: vec![Note::new("already-removed", "worktree no longer exists")],
4682        });
4683    }
4684    let repo = Repository::open(path)
4685        .with_context(|| format!("not a git worktree: {}", path.display()))?;
4686    // The one structural fact deletability keys off — never the branch name.
4687    if !repo.is_worktree() {
4688        return Ok(GitSafety {
4689            is_main: true,
4690            removable: false,
4691            risks: vec![],
4692            info: vec![Note::new(
4693                "main-working-tree",
4694                "the repository's main working tree is never deleted",
4695            )],
4696        });
4697    }
4698
4699    let mut risks = Vec::new();
4700    let mut info = Vec::new();
4701
4702    let (dirty, untracked) = count_dirty_untracked(&repo);
4703    if dirty > 0 {
4704        risks.push(Note::new(
4705            "dirty",
4706            format!("{dirty} modified tracked file(s) would be lost"),
4707        ));
4708    }
4709    if untracked > 0 {
4710        risks.push(Note::new(
4711            "untracked",
4712            format!("{untracked} untracked file(s) would be lost"),
4713        ));
4714    }
4715
4716    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
4717    let state = repo.state();
4718    if state != RepositoryState::Clean {
4719        risks.push(Note::new(
4720            "in-progress",
4721            format!("an in-progress {state:?} operation would be lost"),
4722        ));
4723    }
4724
4725    // Commits reachable only from a detached HEAD are GC'd once the worktree —
4726    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
4727    // or tag) loses nothing, so it is not flagged.
4728    if repo.head_detached().unwrap_or(false) {
4729        let lost = unreachable_commit_count(&repo).unwrap_or(0);
4730        if lost > 0 {
4731            risks.push(Note::new(
4732                "unreachable-commits",
4733                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4734            ));
4735        }
4736    }
4737
4738    // Unpushed commits on a *named* branch survive: removal never deletes the
4739    // branch. Informational only — it must not block or prompt.
4740    if let Some(ahead) = current_branch_ahead(&repo) {
4741        if ahead > 0 {
4742            info.push(Note::new(
4743                "unpushed",
4744                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4745            ));
4746        }
4747    }
4748
4749    Ok(GitSafety {
4750        is_main: false,
4751        removable: true,
4752        risks,
4753        info,
4754    })
4755}
4756
4757/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
4758/// staged or unstaged modification (including conflicts and deletions);
4759/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
4760/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
4761/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
4762/// `(0, 0)` rather than sinking the whole safety check.
4763fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4764    let mut opts = StatusOptions::new();
4765    opts.include_untracked(true)
4766        .recurse_untracked_dirs(true)
4767        .include_ignored(false)
4768        .exclude_submodules(true);
4769    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4770        return (0, 0);
4771    };
4772    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
4773    // it is deliberately excluded from this mask).
4774    let tracked = Status::INDEX_NEW
4775        | Status::INDEX_MODIFIED
4776        | Status::INDEX_DELETED
4777        | Status::INDEX_RENAMED
4778        | Status::INDEX_TYPECHANGE
4779        | Status::WT_MODIFIED
4780        | Status::WT_DELETED
4781        | Status::WT_TYPECHANGE
4782        | Status::WT_RENAMED
4783        | Status::CONFLICTED;
4784    let mut dirty = 0;
4785    let mut untracked = 0;
4786    for entry in statuses.iter() {
4787        let s = entry.status();
4788        if s.contains(Status::WT_NEW) {
4789            untracked += 1;
4790        }
4791        if s.intersects(tracked) {
4792            dirty += 1;
4793        }
4794    }
4795    (dirty, untracked)
4796}
4797
4798/// Counts commits reachable from the (detached) HEAD but from no other ref —
4799/// the commits git would garbage-collect once the worktree's HEAD is gone.
4800/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
4801/// skipped (hiding it would hide the very commits we are counting); every real
4802/// branch/tag/remote ref is hidden, so a tip that any branch also points at
4803/// yields `0` (nothing is actually lost).
4804fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4805    let head_oid = repo.head().ok()?.target()?;
4806    let mut walk = repo.revwalk().ok()?;
4807    walk.push(head_oid).ok()?;
4808    for reference in repo.references().ok()? {
4809        let Ok(reference) = reference else { continue };
4810        // Skip the literal HEAD ref — hiding it would hide the very commits we
4811        // are counting; every real branch/tag/remote ref is hidden below.
4812        if matches!(reference.name(), Ok("HEAD")) {
4813            continue;
4814        }
4815        if let Some(oid) = reference.target() {
4816            let _ = walk.hide(oid);
4817        }
4818    }
4819    Some(walk.flatten().count())
4820}
4821
4822/// Commits the worktree's current branch is ahead of its upstream, or `None`
4823/// when HEAD is detached or the branch tracks no upstream. Reuses
4824/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
4825fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4826    let head = repo.head().ok()?;
4827    if !head.is_branch() {
4828        return None;
4829    }
4830    let branch = git2::Branch::wrap(head);
4831    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4832}
4833
4834/// Resolves the linked worktree whose working directory canonicalizes to
4835/// `target` to its registered name in `main_repo`. Errors when `target` is not
4836/// one of the repo's worktrees — the defensive guard against removing a path
4837/// that opened as a worktree but is not enumerated. Split out so that guard is
4838/// unit-testable without corrupting git's worktree admin state.
4839fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4840    let names = main_repo.worktrees()?;
4841    names
4842        .iter()
4843        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
4844        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
4845        .find(|name| {
4846            main_repo
4847                .find_worktree(name)
4848                .is_ok_and(|wt| canonical(wt.path()) == target)
4849        })
4850        .map(str::to_string)
4851        .ok_or_else(|| {
4852            anyhow!(
4853                "worktree {} is not registered in {}",
4854                target.display(),
4855                main_repo.path().display()
4856            )
4857        })
4858}
4859
4860/// Backoff delays between recursive-removal retries (#1315). A concurrent
4861/// writer — a just-closed window's language server (Metals/Bloop) or
4862/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
4863/// create a file between our directory scan and its `rmdir`, making the removal
4864/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
4865/// waits longer, giving the winding-down process time to quiesce. Total wait
4866/// ~2.75s across four retries; the window teardown the caller already waited on
4867/// dominates it.
4868const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
4869    Duration::from_millis(250),
4870    Duration::from_millis(500),
4871    Duration::from_secs(1),
4872    Duration::from_secs(1),
4873];
4874
4875/// Whether `e` is the transient "directory re-populated under us" race we retry
4876/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
4877/// denied, read-only filesystem) we must surface immediately. Matches the raw
4878/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
4879/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
4880fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
4881    matches!(
4882        e.raw_os_error(),
4883        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
4884    )
4885}
4886
4887/// Recursively removes `dir`, retrying on the transient concurrent-writer race
4888/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
4889/// as success. Non-transient errors surface immediately with the original
4890/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
4891/// `spawn_blocking`), so the between-retry `sleep` is fine.
4892fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
4893    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
4894}
4895
4896/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
4897/// Provoking the real race requires a concurrent writer to lose a timing window,
4898/// so only an injected sequence of errors can drive every branch of the loop —
4899/// exhausting the backoff especially — deterministically and without sleeping out
4900/// the production schedule.
4901fn remove_dir_all_retrying_with(
4902    dir: &Path,
4903    backoff: &[Duration],
4904    mut remove: impl FnMut() -> std::io::Result<()>,
4905) -> Result<()> {
4906    let mut backoff = backoff.iter();
4907    loop {
4908        match remove() {
4909            Ok(()) => return Ok(()),
4910            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4911            Err(e) => {
4912                if is_transient_rmdir_error(&e) {
4913                    if let Some(delay) = backoff.next() {
4914                        std::thread::sleep(*delay);
4915                        continue;
4916                    }
4917                }
4918                return Err(e).with_context(|| {
4919                    format!("failed to remove worktree directory {}", dir.display())
4920                });
4921            }
4922        }
4923    }
4924}
4925
4926/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
4927/// still points at an admin directory a prior failed removal already deleted.
4928/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
4929/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
4930/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
4931/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
4932/// opens) and a normal checkout has a `.git` *directory*, so this matches
4933/// neither.
4934fn is_orphaned_worktree(path: &Path) -> bool {
4935    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
4936    // a linked worktree's gitlink file gets past here.
4937    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
4938        return false;
4939    };
4940    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
4941        return false;
4942    };
4943    let admin = Path::new(admin);
4944    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
4945    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
4946}
4947
4948/// The outcome of a linked-worktree removal, so the audit log can tell "actually
4949/// removed something" from "nothing was there" (#1403). Before this the
4950/// working-tree-gone-but-admin-present case returned `Ok(())` and logged a
4951/// `pruned` lie, leaving the row stuck in the tree view.
4952#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4953enum Removal {
4954    /// The admin metadata (and possibly the working tree) was actually removed.
4955    Pruned,
4956    /// Nothing was there to remove — a truly already-removed worktree.
4957    AlreadyGone,
4958}
4959
4960/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
4961/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
4962/// directory and the admin metadata. Refuses the main working tree (the
4963/// defensive backstop behind the UI gating) and a locked worktree (surfacing
4964/// "unlock first" rather than forcing past the lock). Idempotent: an
4965/// already-removed path is a success.
4966///
4967/// The working tree is removed **first** (retrying to absorb the
4968/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
4969/// This is deliberately the reverse of libgit2's combined
4970/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
4971/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
4972/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
4973/// is already gone). Doing the directory first means a transient failure leaves
4974/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
4975/// the old ordering is detected and its leftover directory cleaned up directly.
4976///
4977/// A working directory that is *already gone* is **not** blindly treated as a
4978/// no-op: a half-removal from outside the daemon (a manual `rm -rf`, an OS
4979/// cleanup) can leave the main repo's `.git/worktrees/<name>/` admin entry behind
4980/// (git marks it `prunable` and the tree view keeps showing the row). That path
4981/// hands off to [`prune_orphaned_admin`], which locates the owning main repo from
4982/// `windows` (or the path's ancestors) and prunes just that entry; only when no
4983/// repo still tracks the path is it reported [`Removal::AlreadyGone`] (#1403).
4984fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
4985    if !path.exists() {
4986        return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
4987    }
4988    let repo = match Repository::open(path) {
4989        Ok(repo) => repo,
4990        // Admin metadata already gone (a prior failed removal); git no longer
4991        // tracks this path, so no prune applies — just delete the leftover.
4992        Err(_) if is_orphaned_worktree(path) => {
4993            remove_dir_all_retrying(path)?;
4994            return Ok(Removal::Pruned);
4995        }
4996        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
4997    };
4998    if !repo.is_worktree() {
4999        bail!(
5000            "refusing to delete the main working tree: {}",
5001            path.display()
5002        );
5003    }
5004    // The Worktree handle lives on the *main* repo (the common dir's parent),
5005    // keyed by name; find it by matching the target path.
5006    let commondir = canonical(repo.commondir());
5007    let main_root = commondir
5008        .parent()
5009        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5010        .to_path_buf();
5011    // Drop the worktree-scoped handle before we delete its directory.
5012    drop(repo);
5013    let main_repo = Repository::open(&main_root)
5014        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5015    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5016    let worktree = main_repo.find_worktree(&name)?;
5017
5018    // Never silently force past a lock (failure mode #6).
5019    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5020        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5021        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5022    }
5023
5024    // Delete the checked-out directory ourselves, retrying past the
5025    // concurrent-writer race (#1315).
5026    remove_dir_all_retrying(path)?;
5027
5028    // The directory is gone; prune only the admin metadata. working_tree(false)
5029    // keeps git2 from re-attempting (and failing on) the now-absent directory;
5030    // valid(true) prunes even though the worktree was valid; locked stays false,
5031    // so a lock (re-checked above) is never forced.
5032    let mut opts = git2::WorktreePruneOptions::new();
5033    opts.valid(true).working_tree(false);
5034    worktree
5035        .prune(Some(&mut opts))
5036        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5037    Ok(Removal::Pruned)
5038}
5039
5040/// The main-repo roots to search when pruning an orphaned worktree whose working
5041/// directory is already gone (#1403). There is no on-disk breadcrumb from the
5042/// vanished working tree back to its repo — the `.git` gitlink lived *inside* the
5043/// deleted directory — so the owner has to be found by enumerating candidates:
5044///
5045/// - **the path's own ancestors**, covering a worktree nested under its repo
5046///   (e.g. `<repo>/.claude/worktrees/<name>`): an existing ancestor that opens as
5047///   the *main* checkout is the owner. `Repository::open` (not `discover`) so only
5048///   a real repo-root ancestor matches, never an intermediate directory.
5049/// - **every main repo the live `windows` resolve to**, covering an external
5050///   worktree that shares no ancestor with its repo. These are the same repos
5051///   whose `worktrees()` enumeration produced the orphaned row, so this is
5052///   guaranteed to include the owner whenever the UI could show a row to close.
5053///
5054/// Deduped, order-preserving (ancestors first).
5055fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5056    let mut roots: Vec<PathBuf> = Vec::new();
5057    let mut push = |root: PathBuf| {
5058        if !roots.contains(&root) {
5059            roots.push(root);
5060        }
5061    };
5062    // Skip `path` itself (gone) via `skip(1)`.
5063    for ancestor in path.ancestors().skip(1) {
5064        if let Ok(repo) = Repository::open(ancestor) {
5065            if !repo.is_worktree() {
5066                if let Some(root) = canonical(repo.commondir()).parent() {
5067                    push(root.to_path_buf());
5068                }
5069            }
5070        }
5071    }
5072    for folder in windows.iter().flat_map(|w| &w.folders) {
5073        if let Ok(repo) = Repository::discover(folder) {
5074            if let Some(root) = canonical(repo.commondir()).parent() {
5075                push(root.to_path_buf());
5076            }
5077        }
5078    }
5079    roots
5080}
5081
5082/// Prunes the leftover `.git/worktrees/<name>/` admin metadata of a worktree
5083/// whose working directory is already gone (#1403). Searches
5084/// `candidate_main_repos` for the main repo that still tracks a worktree
5085/// registered at `path`, prunes just that entry's metadata (`working_tree(false)`
5086/// — the checkout is already gone), and returns [`Removal::Pruned`]. When no
5087/// candidate still tracks the path it is truly already-removed:
5088/// [`Removal::AlreadyGone`]. A locked entry is refused, mirroring
5089/// [`remove_worktree`]'s live path, rather than forced past.
5090fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5091    let target = canonical(path);
5092    for root in candidate_main_repos {
5093        let Ok(main_repo) = Repository::open(root) else {
5094            continue;
5095        };
5096        // Only the main checkout carries the `.git/worktrees/<name>/` admin dir.
5097        if main_repo.is_worktree() {
5098            continue;
5099        }
5100        // Not the owner (or the entry is already pruned) — keep looking.
5101        let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5102            continue;
5103        };
5104        let worktree = main_repo.find_worktree(&name)?;
5105        if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5106            let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5107            bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5108        }
5109        let mut opts = git2::WorktreePruneOptions::new();
5110        opts.valid(true).working_tree(false);
5111        worktree.prune(Some(&mut opts)).with_context(|| {
5112            format!(
5113                "failed to prune orphaned worktree metadata for {}",
5114                path.display()
5115            )
5116        })?;
5117        return Ok(Removal::Pruned);
5118    }
5119    Ok(Removal::AlreadyGone)
5120}
5121
5122#[cfg(test)]
5123#[allow(clippy::unwrap_used, clippy::expect_used)]
5124mod tests {
5125    use super::*;
5126    use crate::test_support::shim::{
5127        retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5128    };
5129    use std::sync::MutexGuard;
5130
5131    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5132        json!({
5133            "key": key,
5134            "folders": [folder],
5135            "repo": repo,
5136            "title": format!("{key}-title"),
5137            "pid": 1234,
5138        })
5139    }
5140
5141    /// Pulls the `windows` array out of a `list`/`status` payload.
5142    fn windows_of(payload: &Value) -> &Vec<Value> {
5143        payload
5144            .get("windows")
5145            .and_then(Value::as_array)
5146            .expect("windows array")
5147    }
5148
5149    #[tokio::test]
5150    async fn name_and_unknown_op() {
5151        let svc = WorktreesService::new();
5152        assert_eq!(svc.name(), "worktrees");
5153        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5154    }
5155
5156    #[tokio::test]
5157    async fn handle_routes_ops_and_shapes_payloads() {
5158        let svc = WorktreesService::new();
5159        // Empty to start.
5160        let payload = svc.handle("list", Value::Null).await.unwrap();
5161        assert_eq!(payload, json!({ "windows": [] }));
5162
5163        // register → { ok: true }, then it shows up in list.
5164        let reply = svc
5165            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5166            .await
5167            .unwrap();
5168        assert_eq!(reply, json!({ "ok": true }));
5169        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5170        assert_eq!(windows.len(), 1);
5171        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5172        assert!(windows[0].get("last_seen").is_some());
5173
5174        // heartbeat known/unknown.
5175        let known = svc
5176            .handle("heartbeat", json!({ "key": "w1" }))
5177            .await
5178            .unwrap();
5179        assert_eq!(known, json!({ "known": true }));
5180        let unknown = svc
5181            .handle("heartbeat", json!({ "key": "nope" }))
5182            .await
5183            .unwrap();
5184        assert_eq!(unknown, json!({ "known": false }));
5185
5186        // reload signals a live window and reports one it does not know.
5187        let reloaded = svc
5188            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5189            .await
5190            .unwrap();
5191        assert_eq!(
5192            reloaded,
5193            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5194        );
5195        assert!(svc.registry.take_reload_pending("w1"));
5196
5197        // unregister removes, then repeats as a no-op success.
5198        let gone = svc
5199            .handle("unregister", json!({ "key": "w1" }))
5200            .await
5201            .unwrap();
5202        assert_eq!(gone, json!({ "removed": true }));
5203        let again = svc
5204            .handle("unregister", json!({ "key": "w1" }))
5205            .await
5206            .unwrap();
5207        assert_eq!(again, json!({ "removed": false }));
5208    }
5209
5210    // --- Reposition op (#1407) ------------------------------------------------
5211
5212    /// A window backend over an in-memory window table that **actually applies**
5213    /// writes, so the adapter's own responsibilities — key resolution, the undo
5214    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
5215    /// no Accessibility grant. The planner/matcher itself is covered by
5216    /// `geometry`'s own tests.
5217    ///
5218    /// Applying the writes is what makes a reposition-then-undo round trip mean
5219    /// anything: against a fixed table the restore would find every window already
5220    /// in its wanted position and correctly report `unchanged`.
5221    #[derive(Clone)]
5222    struct StubBackend {
5223        trusted: bool,
5224        /// One application's windows. `Arc` so every clone the factory hands out —
5225        /// and every op in a test — shares the same mutating table.
5226        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5227        /// Every frame written, in order.
5228        writes: Arc<Mutex<Vec<geometry::Frame>>>,
5229    }
5230
5231    impl StubBackend {
5232        /// One application (pid 900) with two default-format VS Code windows, and
5233        /// two ext-host pids (11, 12) mapping onto it.
5234        fn new(trusted: bool) -> Self {
5235            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5236                title: title.to_string(),
5237                frame: geometry::Frame {
5238                    x,
5239                    y: 0.0,
5240                    width,
5241                    height: 600.0,
5242                },
5243                minimized: false,
5244                fullscreen: false,
5245                standard: true,
5246                focused: false,
5247            };
5248            Self {
5249                trusted,
5250                windows: Arc::new(Mutex::new(vec![
5251                    window("plan.md — ref-tree", 0.0, 800.0),
5252                    window("main.rs — other-tree", 900.0, 500.0),
5253                ])),
5254                writes: Arc::new(Mutex::new(Vec::new())),
5255            }
5256        }
5257
5258        fn writes(&self) -> Vec<geometry::Frame> {
5259            self.writes
5260                .lock()
5261                .unwrap_or_else(PoisonError::into_inner)
5262                .clone()
5263        }
5264
5265        /// The frame a window currently occupies, after any applied writes.
5266        fn frame_of(&self, index: usize) -> geometry::Frame {
5267            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5268        }
5269
5270        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
5271        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5272            let clone = self.clone();
5273            move || clone
5274        }
5275    }
5276
5277    impl geometry::WindowBackend for StubBackend {
5278        fn trusted(&self) -> bool {
5279            self.trusted
5280        }
5281
5282        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5283            pids.iter()
5284                .filter(|p| **p == 11 || **p == 12)
5285                .map(|p| (*p, 900))
5286                .collect()
5287        }
5288
5289        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5290            if app_pid != 900 {
5291                return Ok(Vec::new());
5292            }
5293            Ok(self
5294                .windows
5295                .lock()
5296                .unwrap_or_else(PoisonError::into_inner)
5297                .clone())
5298        }
5299
5300        fn set_frame(
5301            &self,
5302            id: geometry::WindowId,
5303            frame: geometry::Frame,
5304        ) -> Result<geometry::Frame, String> {
5305            self.writes
5306                .lock()
5307                .unwrap_or_else(PoisonError::into_inner)
5308                .push(frame);
5309            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5310            let window = windows
5311                .get_mut(id.index)
5312                .ok_or_else(|| format!("no window at index {}", id.index))?;
5313            window.frame = frame;
5314            Ok(frame)
5315        }
5316    }
5317
5318    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
5319    /// one the stub backend can resolve to an OS window.
5320    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5321        svc.registry.register(
5322            serde_json::from_value(json!({
5323                "key": key,
5324                "folders": [format!("/tmp/{key}")],
5325                "title": title,
5326                "pid": pid,
5327            }))
5328            .expect("valid register payload"),
5329        );
5330    }
5331
5332    #[tokio::test]
5333    async fn reposition_requires_a_resolvable_reference() {
5334        let svc = WorktreesService::new();
5335        // A missing, blank, or unknown reference key is a hard error: with no
5336        // reference there is no geometry to copy, so the request is meaningless.
5337        assert!(svc.handle("reposition", json!({})).await.is_err());
5338        assert!(svc
5339            .handle("reposition", json!({ "reference_key": "  " }))
5340            .await
5341            .is_err());
5342        assert!(svc
5343            .handle("reposition", json!({ "reference_key": "ghost" }))
5344            .await
5345            .is_err());
5346    }
5347
5348    #[tokio::test]
5349    async fn reposition_moves_targets_and_records_an_undo() {
5350        let svc = WorktreesService::new();
5351        register_window(&svc, "ref", "ref-tree", 11);
5352        register_window(&svc, "other", "other-tree", 12);
5353        let backend = StubBackend::new(true);
5354
5355        let reply = svc
5356            .reposition_with(
5357                serde_json::from_value(json!({
5358                    "reference_key": "ref",
5359                    "target_keys": ["other"],
5360                }))
5361                .unwrap(),
5362                backend.factory(),
5363            )
5364            .await
5365            .unwrap();
5366
5367        assert_eq!(reply["trusted"], json!(true));
5368        assert_eq!(reply["moved"], json!(1));
5369        assert_eq!(reply["skipped"], json!(0));
5370        assert_eq!(reply["undoable"], json!(true));
5371        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5372        assert_eq!(reply["results"][0]["key"], json!("other"));
5373        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5374        // Written the reference's own frame, read from the stub's window table.
5375        assert_eq!(backend.writes().len(), 1);
5376        assert_eq!(
5377            backend.writes()[0],
5378            backend.frame_of(0),
5379            "wrote the reference window's own frame"
5380        );
5381        assert_eq!(
5382            backend.frame_of(1),
5383            backend.frame_of(0),
5384            "the target now occupies the reference's frame"
5385        );
5386
5387        // Undo puts it back where it was — the same backend, so it sees the window
5388        // where the move left it — and consumes the record, so a second undo has
5389        // nothing left to replay onto a layout the user may have since redone.
5390        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5391        assert_eq!(undone["moved"], json!(1));
5392        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5393        assert!(undone.get("reference").is_none(), "undo has no reference");
5394        assert_eq!(
5395            backend.frame_of(1),
5396            geometry::Frame {
5397                x: 900.0,
5398                y: 0.0,
5399                width: 500.0,
5400                height: 600.0,
5401            },
5402            "restored to exactly the pre-move frame"
5403        );
5404
5405        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5406        assert_eq!(
5407            again,
5408            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5409        );
5410    }
5411
5412    #[tokio::test]
5413    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5414        let svc = WorktreesService::new();
5415        register_window(&svc, "ref", "ref-tree", 11);
5416        register_window(&svc, "other", "other-tree", 12);
5417        let backend = StubBackend::new(true);
5418
5419        let reply = svc
5420            .reposition_with(
5421                serde_json::from_value(json!({
5422                    "reference_key": "ref",
5423                    "target_keys": ["other"],
5424                    "check": true,
5425                }))
5426                .unwrap(),
5427                backend.factory(),
5428            )
5429            .await
5430            .unwrap();
5431
5432        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5433        assert!(
5434            reply.get("undoable").is_none(),
5435            "a dry run leaves nothing to undo"
5436        );
5437        assert!(
5438            backend.writes().is_empty(),
5439            "a dry run must not touch a window"
5440        );
5441    }
5442
5443    #[tokio::test]
5444    async fn reposition_reports_a_missing_permission_as_data() {
5445        let svc = WorktreesService::new();
5446        register_window(&svc, "ref", "ref-tree", 11);
5447        register_window(&svc, "other", "other-tree", 12);
5448        let backend = StubBackend::new(false);
5449
5450        let reply = svc
5451            .reposition_with(
5452                serde_json::from_value(json!({
5453                    "reference_key": "ref",
5454                    "target_keys": ["other"],
5455                }))
5456                .unwrap(),
5457                backend.factory(),
5458            )
5459            .await
5460            .unwrap();
5461
5462        // Not an error: the client branches on `trusted` to offer the user a link
5463        // to the Accessibility settings pane.
5464        assert_eq!(reply["trusted"], json!(false));
5465        assert_eq!(reply["moved"], json!(0));
5466        assert!(backend.writes().is_empty());
5467    }
5468
5469    #[tokio::test]
5470    async fn a_stale_target_key_is_skipped_not_fatal() {
5471        let svc = WorktreesService::new();
5472        register_window(&svc, "ref", "ref-tree", 11);
5473        register_window(&svc, "other", "other-tree", 12);
5474        let backend = StubBackend::new(true);
5475
5476        let reply = svc
5477            .reposition_with(
5478                serde_json::from_value(json!({
5479                    "reference_key": "ref",
5480                    // A window that closed since the tree row was rendered, the
5481                    // reference itself, and a real target.
5482                    "target_keys": ["closed-since", "ref", "other"],
5483                }))
5484                .unwrap(),
5485                backend.factory(),
5486            )
5487            .await
5488            .unwrap();
5489
5490        let outcomes: Vec<&str> = reply["results"]
5491            .as_array()
5492            .unwrap()
5493            .iter()
5494            .map(|r| r["outcome"].as_str().unwrap())
5495            .collect();
5496        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5497        assert_eq!(reply["moved"], json!(1));
5498        assert_eq!(reply["skipped"], json!(2));
5499    }
5500
5501    #[tokio::test]
5502    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5503        let svc = WorktreesService::new();
5504        // Two windows share a root name, so the *reference* cannot be resolved and
5505        // the whole batch is refused before any target is attempted.
5506        register_window(&svc, "ref", "twin", 11);
5507        register_window(&svc, "other", "other-tree", 12);
5508        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5509        // `mut` binding — and the same shared table backs the factory's clone.
5510        let backend = StubBackend::new(true);
5511        {
5512            let mut windows = backend
5513                .windows
5514                .lock()
5515                .unwrap_or_else(PoisonError::into_inner);
5516            windows[0].title = "a.rs — twin".to_string();
5517            windows[1].title = "b.rs — twin".to_string();
5518        }
5519
5520        let reply = svc
5521            .reposition_with(
5522                serde_json::from_value(json!({
5523                    "reference_key": "ref",
5524                    "target_keys": ["other"],
5525                }))
5526                .unwrap(),
5527                backend.factory(),
5528            )
5529            .await
5530            .unwrap();
5531
5532        assert_eq!(reply["trusted"], json!(true));
5533        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5534        assert!(
5535            reply["blocked"]["detail"]
5536                .as_str()
5537                .is_some_and(|d| d.contains("twin")),
5538            "the reason should name the ambiguous title: {reply}"
5539        );
5540        assert_eq!(reply["results"], json!([]), "no target is attempted");
5541        assert!(reply.get("undoable").is_none());
5542        assert!(backend.writes().is_empty());
5543
5544        // And nothing was recorded, so a following undo has nothing to replay.
5545        let undone = svc
5546            .reposition_undo_with(StubBackend::new(true).factory())
5547            .await
5548            .unwrap();
5549        assert_eq!(undone["moved"], json!(0));
5550    }
5551
5552    #[tokio::test]
5553    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5554        let svc = WorktreesService::new();
5555        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5556        assert_eq!(reply["moved"], json!(0));
5557        assert_eq!(reply["results"], json!([]));
5558    }
5559
5560    #[test]
5561    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5562        let empty = geometry::RepositionReport {
5563            trusted: true,
5564            blocked: None,
5565            reference: None,
5566            results: Vec::new(),
5567            undo: Vec::new(),
5568        };
5569        assert_eq!(outcome_kinds(&empty), "-");
5570    }
5571
5572    #[tokio::test]
5573    async fn handle_rejects_missing_or_empty_key() {
5574        let svc = WorktreesService::new();
5575        // register validates a present, non-blank key.
5576        assert!(svc.handle("register", json!({})).await.is_err());
5577        assert!(svc
5578            .handle("register", json!({ "key": "  " }))
5579            .await
5580            .is_err());
5581        // heartbeat/unregister require the key via `require_str`.
5582        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5583        assert!(svc.handle("unregister", json!({})).await.is_err());
5584    }
5585
5586    #[test]
5587    fn display_name_prefers_repo_then_folder_basename() {
5588        let base = WindowEntry {
5589            key: "k".to_string(),
5590            folders: vec![PathBuf::from("/home/me/project")],
5591            repo: Some("my-repo".to_string()),
5592            title: None,
5593            pid: None,
5594            last_seen: Utc::now(),
5595        };
5596        assert_eq!(display_name(&base), "my-repo");
5597
5598        let no_repo = WindowEntry {
5599            repo: None,
5600            ..base.clone()
5601        };
5602        assert_eq!(display_name(&no_repo), "project");
5603
5604        let nothing = WindowEntry {
5605            repo: None,
5606            folders: vec![],
5607            ..base.clone()
5608        };
5609        assert_eq!(display_name(&nothing), "(no folder)");
5610
5611        // A folder with no basename (the filesystem root) falls back to its
5612        // displayed path rather than panicking or yielding an empty name.
5613        let rootish = WindowEntry {
5614            repo: None,
5615            folders: vec![PathBuf::from("/")],
5616            ..base
5617        };
5618        assert_eq!(display_name(&rootish), "/");
5619    }
5620
5621    #[test]
5622    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5623        let now = Utc::now();
5624        let entries = vec![
5625            // A folderless window has nothing to focus, so it stays a plain
5626            // Label; a title equal to the name collapses to just the name. It
5627            // leads the list so the focus-action lookup below is exercised
5628            // against a leading non-Action item it has to skip.
5629            WindowEntry {
5630                key: "k2".to_string(),
5631                folders: vec![],
5632                repo: Some("solo".to_string()),
5633                title: Some("solo".to_string()),
5634                pid: None,
5635                last_seen: now,
5636            },
5637            // A folder-bearing, non-repo window: one clickable Action whose label
5638            // is the stats line ("name · title", since /tmp is not a git repo).
5639            WindowEntry {
5640                key: "k1".to_string(),
5641                folders: vec![PathBuf::from("/tmp/a")],
5642                repo: Some("repo".to_string()),
5643                title: Some("a branch".to_string()),
5644                pid: None,
5645                last_seen: now,
5646            },
5647        ];
5648        let items = window_menu_items(&entries);
5649        // Exactly one item per window — no duplicate label, no separator.
5650        assert_eq!(items.len(), 2);
5651        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5652
5653        // The folder-bearing window is a single clickable action carrying the
5654        // stats label (the old label + Focus action, merged).
5655        let action = items
5656            .iter()
5657            .find_map(|i| match i {
5658                MenuItem::Action(a) => Some(a),
5659                _ => None,
5660            })
5661            .expect("a focus action");
5662        assert_eq!(action.id, "focus:k1");
5663        assert_eq!(action.label, "repo · a branch");
5664
5665        // The folderless window is a non-clickable label (not "solo · solo").
5666        let labels: Vec<&str> = items
5667            .iter()
5668            .filter_map(|i| match i {
5669                MenuItem::Label(t) => Some(t.as_str()),
5670                _ => None,
5671            })
5672            .collect();
5673        assert_eq!(labels, vec!["solo"]);
5674    }
5675
5676    #[tokio::test]
5677    async fn menu_and_status_shapes() {
5678        let svc = WorktreesService::new();
5679        // Empty.
5680        let menu = svc.menu();
5681        assert_eq!(menu.title, "Worktrees");
5682        assert!(matches!(
5683            menu.items.first(),
5684            Some(MenuItem::Label(text)) if text == "No open windows"
5685        ));
5686        let status = svc.status().await;
5687        assert_eq!(status.name, "worktrees");
5688        assert!(status.healthy);
5689        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5690
5691        // Two folder-bearing windows in the same repo, plus one folderless
5692        // window that shares the repo but has nothing for `code` to open.
5693        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5694            .await
5695            .unwrap();
5696        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5697            .await
5698            .unwrap();
5699        svc.handle(
5700            "register",
5701            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5702        )
5703        .await
5704        .unwrap();
5705        let status = svc.status().await;
5706        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5707
5708        let menu = svc.menu();
5709        // One line per window — no separator, no duplicate label.
5710        assert_eq!(menu.items.len(), 3);
5711        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5712        let action_ids: Vec<&str> = menu
5713            .items
5714            .iter()
5715            .filter_map(|i| match i {
5716                MenuItem::Action(a) => Some(a.id.as_str()),
5717                _ => None,
5718            })
5719            .collect();
5720        // The two folder-bearing windows are clickable; the folderless one is a
5721        // plain Label, so it never yields a focus action.
5722        assert!(action_ids.contains(&"focus:w1"));
5723        assert!(action_ids.contains(&"focus:w2"));
5724        assert!(!action_ids.contains(&"focus:w3"));
5725    }
5726
5727    #[test]
5728    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5729        // With no tokio runtime, the background task is never spawned, so the
5730        // bare service keeps computing `menu()` inline (what the tests rely on).
5731        let svc = WorktreesService::new();
5732        svc.start_menu_refresh();
5733        assert!(svc.refresh.lock().unwrap().is_none());
5734    }
5735
5736    #[tokio::test]
5737    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5738        let svc = WorktreesService::new();
5739        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5740            .await
5741            .unwrap();
5742        // Before the task runs, `menu()` computes inline from an empty cache.
5743        assert!(svc.menu_cache.lock().unwrap().is_none());
5744
5745        svc.start_menu_refresh();
5746        // Idempotent: a second call does not start a second task.
5747        svc.start_menu_refresh();
5748
5749        // The task fills the cache off the main thread; poll briefly for it.
5750        let mut filled = false;
5751        for _ in 0..100 {
5752            if svc.menu_cache.lock().unwrap().is_some() {
5753                filled = true;
5754                break;
5755            }
5756            tokio::time::sleep(Duration::from_millis(10)).await;
5757        }
5758        assert!(filled, "background refresh should populate the menu cache");
5759
5760        // `menu()` now serves the cache: one clickable line for the window.
5761        let menu = svc.menu();
5762        assert_eq!(menu.title, "Worktrees");
5763        assert!(menu
5764            .items
5765            .iter()
5766            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5767
5768        // Shutdown cancels and joins the task, clearing the handle.
5769        svc.shutdown().await;
5770        assert!(svc.refresh.lock().unwrap().is_none());
5771    }
5772
5773    #[tokio::test]
5774    async fn default_constructs_an_empty_service() {
5775        let svc = WorktreesService::default();
5776        let payload = svc.handle("list", Value::Null).await.unwrap();
5777        assert_eq!(payload, json!({ "windows": [] }));
5778    }
5779
5780    // --- Push subscription (#1267) -----------------------------------------
5781
5782    #[tokio::test]
5783    async fn subscribe_streams_only_for_the_subscribe_op() {
5784        let svc = WorktreesService::new();
5785        // The one streaming op yields a stream; every other op (including the
5786        // request/reply worktrees ops) declines, so the server dispatches them
5787        // normally.
5788        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5789        assert!(svc.subscribe("list", &Value::Null).is_none());
5790        assert!(svc.subscribe("register", &Value::Null).is_none());
5791        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5792    }
5793
5794    #[tokio::test]
5795    async fn subscribe_snapshot_matches_the_tree_op() {
5796        let dir = tempfile::tempdir().unwrap();
5797        let repo = init_repo(dir.path());
5798        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5799        repo.set_head("refs/heads/main").unwrap();
5800
5801        let svc = WorktreesService::new();
5802        let stream = svc
5803            .subscribe("subscribe", &Value::Null)
5804            .expect("subscribe stream");
5805        // No windows yet → no repos derived; the toggle rides along at its
5806        // default (show all).
5807        assert_eq!(
5808            stream.snapshot().await,
5809            json!({ "repos": [], "show_closed": true })
5810        );
5811
5812        // A window opens on the repo → the snapshot carries it, byte-identical to
5813        // what the `tree` op returns for the same registry state.
5814        svc.handle(
5815            "register",
5816            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5817        )
5818        .await
5819        .unwrap();
5820        let snap = stream.snapshot().await;
5821        let tree = svc.handle("tree", Value::Null).await.unwrap();
5822        assert_eq!(snap, tree);
5823        let repos = snap["repos"].as_array().expect("repos array");
5824        assert_eq!(repos.len(), 1);
5825        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5826    }
5827
5828    #[tokio::test]
5829    async fn subscribe_changed_wakes_on_register() {
5830        let svc = WorktreesService::new();
5831        let mut stream = svc
5832            .subscribe("subscribe", &Value::Null)
5833            .expect("subscribe stream");
5834        // Idle: `changed()` must not resolve without a registry change.
5835        tokio::select! {
5836            () = stream.changed() => panic!("changed resolved with no registry change"),
5837            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5838        }
5839        // A register bumps the change-notify → `changed()` resolves promptly.
5840        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5841            .await
5842            .unwrap();
5843        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5844            .await
5845            .expect("changed should resolve after a register");
5846    }
5847
5848    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5849
5850    #[tokio::test]
5851    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5852        let reg = Arc::new(WorktreesRegistry::new());
5853        // A long TTL so only the generation gate is exercised here.
5854        let cache = TreeSnapshotCache::with_ttl(
5855            reg,
5856            Arc::new(PrStatusCache::new()),
5857            Duration::from_secs(60),
5858        );
5859        // The first read builds once.
5860        let first = cache.snapshot().await;
5861        assert_eq!(cache.compute_count(), 1);
5862        // Further reads with no registry change and within the TTL reuse the
5863        // cached value — no extra build, byte-identical result.
5864        let second = cache.snapshot().await;
5865        assert_eq!(
5866            cache.compute_count(),
5867            1,
5868            "an unchanged read must not rebuild"
5869        );
5870        assert_eq!(first, second);
5871    }
5872
5873    #[tokio::test]
5874    async fn tree_cache_single_flights_a_read_burst() {
5875        let reg = Arc::new(WorktreesRegistry::new());
5876        let cache = Arc::new(TreeSnapshotCache::with_ttl(
5877            reg,
5878            Arc::new(PrStatusCache::new()),
5879            Duration::from_secs(60),
5880        ));
5881        // A burst of concurrent readers — as N subscriber streams would wake
5882        // together on a change/tick — collapses to exactly one build; the rest
5883        // read the shared result (the acceptance criterion).
5884        let mut handles = Vec::new();
5885        for _ in 0..16 {
5886            let cache = cache.clone();
5887            handles.push(tokio::spawn(async move { cache.snapshot().await }));
5888        }
5889        let mut results = Vec::new();
5890        for handle in handles {
5891            results.push(handle.await.unwrap());
5892        }
5893        assert_eq!(
5894            cache.compute_count(),
5895            1,
5896            "a concurrent read burst must build the tree once"
5897        );
5898        assert!(
5899            results.windows(2).all(|w| w[0] == w[1]),
5900            "every reader must observe the identical snapshot"
5901        );
5902    }
5903
5904    #[tokio::test]
5905    async fn tree_cache_rebuilds_on_registry_change() {
5906        let reg = Arc::new(WorktreesRegistry::new());
5907        let cache = TreeSnapshotCache::with_ttl(
5908            reg.clone(),
5909            Arc::new(PrStatusCache::new()),
5910            Duration::from_secs(60),
5911        );
5912        cache.snapshot().await;
5913        assert_eq!(cache.compute_count(), 1);
5914        // A registry change bumps the generation, so the next read rebuilds even
5915        // though the (long) TTL has not expired — subscribers never see a stale
5916        // visible set.
5917        assert!(reg.set_show_closed(false));
5918        cache.snapshot().await;
5919        assert_eq!(
5920            cache.compute_count(),
5921            2,
5922            "a generation bump must force a rebuild"
5923        );
5924    }
5925
5926    #[tokio::test]
5927    async fn tree_cache_rebuilds_after_ttl_expiry() {
5928        let reg = Arc::new(WorktreesRegistry::new());
5929        // A zero TTL: every read is already past it, so a pure on-disk git change
5930        // still surfaces on the next tick with no registry bump needed.
5931        let cache =
5932            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5933        cache.snapshot().await;
5934        cache.snapshot().await;
5935        assert_eq!(
5936            cache.compute_count(),
5937            2,
5938            "an expired TTL must force a rebuild each read"
5939        );
5940    }
5941
5942    #[tokio::test]
5943    async fn subscribe_streams_share_one_build_per_generation() {
5944        let svc = WorktreesService::new();
5945        let s1 = svc
5946            .subscribe("subscribe", &Value::Null)
5947            .expect("subscribe stream");
5948        let s2 = svc
5949            .subscribe("subscribe", &Value::Null)
5950            .expect("subscribe stream");
5951        // Two windows' streams sampling the same registry state build the tree
5952        // once, not once per stream (#1303) — they share the service's cache.
5953        let a = s1.snapshot().await;
5954        let b = s2.snapshot().await;
5955        assert_eq!(a, b);
5956        assert_eq!(
5957            svc.tree_cache.compute_count(),
5958            1,
5959            "N streams on one generation must share a single build"
5960        );
5961    }
5962
5963    // --- Show/hide-closed toggle (#1301) -----------------------------------
5964
5965    #[tokio::test]
5966    async fn set_show_closed_toggles_the_snapshot_field() {
5967        let svc = WorktreesService::new();
5968        // The snapshot carries the toggle; it defaults to show-all.
5969        assert_eq!(
5970            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5971            json!(true)
5972        );
5973        // Setting it flips the field the next snapshot reports.
5974        let reply = svc
5975            .handle("set-show-closed", json!({ "show_closed": false }))
5976            .await
5977            .unwrap();
5978        assert_eq!(reply, json!({ "ok": true }));
5979        assert_eq!(
5980            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5981            json!(false)
5982        );
5983    }
5984
5985    #[tokio::test]
5986    async fn set_show_closed_rejects_a_non_boolean_payload() {
5987        let svc = WorktreesService::new();
5988        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5989        assert!(svc
5990            .handle("set-show-closed", json!({ "show_closed": "yes" }))
5991            .await
5992            .is_err());
5993    }
5994
5995    #[tokio::test]
5996    async fn set_show_closed_wakes_the_subscription() {
5997        let svc = WorktreesService::new();
5998        let mut stream = svc
5999            .subscribe("subscribe", &Value::Null)
6000            .expect("subscribe stream");
6001        // A real flip bumps the change-notify → `changed()` resolves promptly.
6002        svc.handle("set-show-closed", json!({ "show_closed": false }))
6003            .await
6004            .unwrap();
6005        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6006            .await
6007            .expect("changed should resolve after a toggle flip");
6008        // The pushed snapshot now reflects the new toggle.
6009        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6010    }
6011
6012    #[tokio::test]
6013    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6014        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
6015        // drops it (skip-if-false), so the extension colours the icon off the flag.
6016        let dir = tempfile::tempdir().unwrap();
6017        github_repo(dir.path());
6018        let svc = WorktreesService::new();
6019        svc.handle(
6020            "register",
6021            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6022        )
6023        .await
6024        .unwrap();
6025
6026        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6027        assert!(
6028            repo.get("polling_enabled").is_none(),
6029            "default off omits the flag: {repo:?}"
6030        );
6031
6032        let reply = svc
6033            .handle(
6034                "set-polling",
6035                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6036            )
6037            .await
6038            .unwrap();
6039        assert_eq!(reply, json!({ "ok": true }));
6040        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6041        assert_eq!(repo["polling_enabled"], json!(true));
6042
6043        svc.handle(
6044            "set-polling",
6045            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6046        )
6047        .await
6048        .unwrap();
6049        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6050        assert!(repo.get("polling_enabled").is_none());
6051    }
6052
6053    #[tokio::test]
6054    async fn set_polling_rejects_missing_or_empty_fields() {
6055        let svc = WorktreesService::new();
6056        // Missing `enabled`.
6057        assert!(svc
6058            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6059            .await
6060            .is_err());
6061        // Missing `owner`/`name`.
6062        assert!(svc
6063            .handle("set-polling", json!({ "enabled": true }))
6064            .await
6065            .is_err());
6066        // Blank `owner`/`name`.
6067        assert!(svc
6068            .handle(
6069                "set-polling",
6070                json!({ "owner": " ", "name": "n", "enabled": true })
6071            )
6072            .await
6073            .is_err());
6074    }
6075
6076    #[tokio::test]
6077    async fn set_polling_wakes_the_subscription() {
6078        let dir = tempfile::tempdir().unwrap();
6079        github_repo(dir.path());
6080        let svc = WorktreesService::new();
6081        svc.handle(
6082            "register",
6083            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6084        )
6085        .await
6086        .unwrap();
6087        let mut stream = svc
6088            .subscribe("subscribe", &Value::Null)
6089            .expect("subscribe stream");
6090        svc.handle(
6091            "set-polling",
6092            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6093        )
6094        .await
6095        .unwrap();
6096        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6097            .await
6098            .expect("changed should resolve after enabling a repo");
6099        let repo = repos_of(&stream.snapshot().await)[0].clone();
6100        assert_eq!(repo["polling_enabled"], json!(true));
6101    }
6102
6103    #[tokio::test]
6104    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6105        // The "drop existing badges immediately" requirement (#1376), done
6106        // daemon-side: the fold skips a not-polled repo, so a disable strips the
6107        // badge on the very next snapshot rather than waiting for a poll.
6108        let dir = tempfile::tempdir().unwrap();
6109        let repo = github_repo(dir.path());
6110        let head = repo.head().unwrap().target().unwrap().to_string();
6111        let svc = WorktreesService::new();
6112        svc.handle(
6113            "register",
6114            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6115        )
6116        .await
6117        .unwrap();
6118        svc.registry.set_polling("rust-works", "omni-dev", true);
6119
6120        let mut badges = HashMap::new();
6121        badges.insert(
6122            PrTarget {
6123                owner: "rust-works".into(),
6124                name: "omni-dev".into(),
6125                branch: "main".into(),
6126            },
6127            pr(pending_badge(7, &head)),
6128        );
6129        svc.pr_cache.replace(badges);
6130
6131        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6132        assert_eq!(wt["pr"]["number"], json!(7));
6133
6134        svc.handle(
6135            "set-polling",
6136            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6137        )
6138        .await
6139        .unwrap();
6140        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6141        assert!(
6142            wt.get("pr").is_none(),
6143            "a disabled repo must carry no badge: {wt:?}"
6144        );
6145    }
6146
6147    #[tokio::test]
6148    async fn an_expired_lease_drops_the_flag_and_badges() {
6149        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
6150        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
6151        // poller would no longer watch it — all reaped on read, no timer.
6152        let dir = tempfile::tempdir().unwrap();
6153        let repo = github_repo(dir.path());
6154        let head = repo.head().unwrap().target().unwrap().to_string();
6155        let svc = WorktreesService::new();
6156        svc.handle(
6157            "register",
6158            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6159        )
6160        .await
6161        .unwrap();
6162        svc.registry.set_polling("rust-works", "omni-dev", true);
6163        let mut badges = HashMap::new();
6164        badges.insert(
6165            PrTarget {
6166                owner: "rust-works".into(),
6167                name: "omni-dev".into(),
6168                branch: "main".into(),
6169            },
6170            pr(pending_badge(7, &head)),
6171        );
6172        svc.pr_cache.replace(badges);
6173
6174        // Leased: flag stamped, badge folded, and the poller would watch it.
6175        let snap = svc.handle("tree", Value::Null).await.unwrap();
6176        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6177        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6178        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6179
6180        // Force the lease into the past — as 15 minutes elapsing would.
6181        svc.registry.set_polling_expiry(
6182            "rust-works",
6183            "omni-dev",
6184            Utc::now() - chrono::Duration::minutes(1),
6185        );
6186
6187        let snap = svc.handle("tree", Value::Null).await.unwrap();
6188        let repo0 = &repos_of(&snap)[0];
6189        assert!(
6190            repo0.get("polling_enabled").is_none(),
6191            "expired lease drops the flag: {repo0:?}"
6192        );
6193        assert!(
6194            repo0["worktrees"][0].get("pr").is_none(),
6195            "expired lease drops the badge"
6196        );
6197        assert!(
6198            pr_targets_from_snapshot(&snap).is_empty(),
6199            "the poller no longer watches an expired repo"
6200        );
6201    }
6202
6203    #[tokio::test]
6204    async fn polling_prefs_persist_across_reloads_with_0600() {
6205        // The enable set survives a daemon restart (#1376): a change writes the
6206        // `0600` file, and a fresh service seeded from it comes up enabled.
6207        let dir = tempfile::tempdir().unwrap();
6208        let prefs = dir.path().join("worktrees-polling.json");
6209
6210        let svc = WorktreesService::new();
6211        svc.load_polling_prefs(prefs.clone());
6212        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6213        svc.handle(
6214            "set-polling",
6215            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6216        )
6217        .await
6218        .unwrap();
6219        assert!(prefs.exists());
6220        #[cfg(unix)]
6221        {
6222            use std::os::unix::fs::PermissionsExt;
6223            assert_eq!(
6224                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6225                0o600
6226            );
6227        }
6228
6229        // A new service reloads the enabled set from that file.
6230        let svc2 = WorktreesService::new();
6231        svc2.load_polling_prefs(prefs.clone());
6232        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6233
6234        // Disabling rewrites the file, so the next reload has nothing enabled.
6235        svc2.handle(
6236            "set-polling",
6237            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6238        )
6239        .await
6240        .unwrap();
6241        let svc3 = WorktreesService::new();
6242        svc3.load_polling_prefs(prefs);
6243        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6244    }
6245
6246    #[test]
6247    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6248        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
6249        // path is logged and treated as "nothing enabled" rather than wedging the
6250        // service. A missing file is already the first-run default (covered by the
6251        // persistence round-trip above); this exercises the two error branches.
6252        let dir = tempfile::tempdir().unwrap();
6253
6254        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
6255        let corrupt = dir.path().join("worktrees-polling.json");
6256        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6257        let svc = WorktreesService::new();
6258        svc.load_polling_prefs(corrupt);
6259        assert!(svc.registry.enabled_polling_repos().is_empty());
6260
6261        // A directory at the path — the (non-NotFound) read error is swallowed too.
6262        let as_dir = dir.path().join("is-a-directory");
6263        std::fs::create_dir(&as_dir).unwrap();
6264        let svc2 = WorktreesService::new();
6265        svc2.load_polling_prefs(as_dir);
6266        assert!(svc2.registry.enabled_polling_repos().is_empty());
6267    }
6268
6269    #[tokio::test]
6270    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6271        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
6272        // the user has not enabled polling for it — so the poller spawns no `gh`.
6273        let dir = tempfile::tempdir().unwrap();
6274        github_repo(dir.path());
6275        let bin_dir = tempfile::tempdir().unwrap();
6276        let marker = bin_dir.path().join("spawned");
6277        let fake = bin_dir.path().join("fake-gh");
6278        std::fs::write(
6279            &fake,
6280            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6281        )
6282        .unwrap();
6283        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6284        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6285        std::fs::set_permissions(&fake, perms).unwrap();
6286
6287        let svc = WorktreesService::new();
6288        svc.handle(
6289            "register",
6290            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6291        )
6292        .await
6293        .unwrap();
6294        // Deliberately NOT enabling polling for the repo.
6295        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6296        tokio::time::sleep(Duration::from_millis(200)).await;
6297        svc.shutdown().await;
6298        assert!(
6299            !marker.exists(),
6300            "a registered-but-not-enabled repo must drive zero gh"
6301        );
6302    }
6303
6304    #[tokio::test]
6305    async fn menu_action_rejects_unknown_and_missing_window() {
6306        let svc = WorktreesService::new();
6307        assert!(svc.menu_action("bogus").await.is_err());
6308        // A focus for a key with no registration errors rather than spawning.
6309        assert!(svc.menu_action("focus:nope").await.is_err());
6310        svc.shutdown().await;
6311    }
6312
6313    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
6314    /// variable (via `resolve_code_binary` → `focus_window`) —
6315    /// `menu_action_focus_resolves_folder_and_spawns` and
6316    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
6317    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
6318    /// transient overlap under the harness's test parallelism is benign.
6319    struct VscodeBinGuard(Option<std::ffi::OsString>);
6320    impl Drop for VscodeBinGuard {
6321        fn drop(&mut self) {
6322            match self.0.take() {
6323                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6324                None => std::env::remove_var(VSCODE_BIN_ENV),
6325            }
6326        }
6327    }
6328
6329    #[tokio::test]
6330    async fn menu_action_focus_resolves_folder_and_spawns() {
6331        let dir = tempfile::tempdir().unwrap();
6332        let svc = WorktreesService::new();
6333        svc.handle(
6334            "register",
6335            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6336        )
6337        .await
6338        .unwrap();
6339
6340        // Point the launcher at a harmless binary so the spawn deterministically
6341        // succeeds and the focus path returns Ok.
6342        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6343        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6344        svc.menu_action("focus:w1").await.unwrap();
6345    }
6346
6347    #[tokio::test]
6348    async fn open_rejects_missing_relative_or_nonexistent_path() {
6349        let svc = WorktreesService::new();
6350        // A missing `path` is a payload error.
6351        assert!(svc.handle("open", json!({})).await.is_err());
6352        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6353        // A relative path is rejected before any spawn — this is also what
6354        // blocks a `-`-leading argument from reaching `code` as a flag.
6355        assert!(svc
6356            .handle("open", json!({ "path": "relative/dir" }))
6357            .await
6358            .is_err());
6359        assert!(svc
6360            .handle("open", json!({ "path": "-flag" }))
6361            .await
6362            .is_err());
6363        // An absolute path that does not exist is rejected before any spawn, so
6364        // no launcher is needed for these guard cases.
6365        assert!(svc
6366            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6367            .await
6368            .is_err());
6369        svc.shutdown().await;
6370    }
6371
6372    #[tokio::test]
6373    async fn open_focuses_an_existing_absolute_dir() {
6374        let dir = tempfile::tempdir().unwrap();
6375        let svc = WorktreesService::new();
6376        // Pin the launcher to a harmless binary so the spawn deterministically
6377        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6378        // path, `open` takes the folder straight from the payload — no prior
6379        // `register` is required.
6380        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6381        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6382        let reply = svc
6383            .handle("open", json!({ "path": dir.path() }))
6384            .await
6385            .unwrap();
6386        assert_eq!(reply, json!({ "ok": true }));
6387        svc.shutdown().await;
6388    }
6389
6390    #[test]
6391    fn focus_window_with_validates_folder_then_spawns() {
6392        let dir = tempfile::tempdir().unwrap();
6393        // Non-absolute and missing-directory folders are rejected before spawn.
6394        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6395        assert!(
6396            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6397        );
6398        // A valid absolute directory spawns the launcher successfully.
6399        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6400        // A missing launcher surfaces the spawn error (with context), not Ok.
6401        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6402    }
6403
6404    #[test]
6405    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6406        // Env override wins outright.
6407        assert_eq!(
6408            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6409            PathBuf::from("/custom/code")
6410        );
6411        // No override: the first existing candidate is chosen.
6412        let existing = tempfile::NamedTempFile::new().unwrap();
6413        let existing_path = existing.path().to_str().unwrap();
6414        assert_eq!(
6415            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6416            PathBuf::from(existing_path)
6417        );
6418        // Nothing exists: fall back to bare `code` on PATH.
6419        assert_eq!(
6420            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6421            PathBuf::from("code")
6422        );
6423        // The real-env wrapper resolves without panicking.
6424        let _ = resolve_code_binary();
6425    }
6426
6427    // --- Git enrichment (#1186) --------------------------------------------
6428
6429    /// Initializes a fresh repo with a deterministic identity so `commit()`
6430    /// works without depending on a global git config.
6431    fn init_repo(dir: &Path) -> Repository {
6432        let repo = Repository::init(dir).unwrap();
6433        let mut cfg = repo.config().unwrap();
6434        cfg.set_str("user.name", "Test").unwrap();
6435        cfg.set_str("user.email", "test@example.com").unwrap();
6436        repo
6437    }
6438
6439    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6440    /// optionally moving `refname` to it, and returns its oid.
6441    fn empty_commit(
6442        repo: &Repository,
6443        refname: Option<&str>,
6444        parents: &[&git2::Commit<'_>],
6445        msg: &str,
6446    ) -> git2::Oid {
6447        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6448        let tree = repo
6449            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6450            .unwrap();
6451        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6452            .unwrap()
6453    }
6454
6455    /// Commits `content` as file `name` onto `refname`, chaining off its current
6456    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6457    /// the file is checked out into a worktree and can then be modified to
6458    /// produce a dirty (tracked) status.
6459    fn commit_file(
6460        repo: &Repository,
6461        refname: &str,
6462        name: &str,
6463        content: &[u8],
6464        msg: &str,
6465    ) -> git2::Oid {
6466        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6467        let blob = repo.blob(content).unwrap();
6468        let mut builder = repo.treebuilder(None).unwrap();
6469        builder.insert(name, blob, 0o100_644).unwrap();
6470        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6471        let parent = repo
6472            .refname_to_id(refname)
6473            .ok()
6474            .and_then(|oid| repo.find_commit(oid).ok());
6475        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6476        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6477            .unwrap()
6478    }
6479
6480    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6481    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6482    fn diverging_repo(dir: &Path) -> Repository {
6483        let repo = init_repo(dir);
6484        // A: the shared base on `main`.
6485        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6486        let a_commit = repo.find_commit(a).unwrap();
6487        // origin/main diverges to C, a sibling of the local tip.
6488        let c = empty_commit(&repo, None, &[&a_commit], "C");
6489        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6490            .unwrap();
6491        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6492        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6493        // Release the commit's borrow of `repo` so it can be returned.
6494        drop(a_commit);
6495        repo.set_head("refs/heads/main").unwrap();
6496        // Configure the tracking relationship so `upstream()` resolves.
6497        let mut cfg = repo.config().unwrap();
6498        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6499            .unwrap();
6500        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6501            .unwrap();
6502        cfg.set_str("branch.main.remote", "origin").unwrap();
6503        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6504        repo
6505    }
6506
6507    /// Builds a repo whose `main` has **no upstream configured** but is 1 commit
6508    /// behind a resolvable `origin/main` — the "no own upstream, but behind the
6509    /// default branch" case [`folder_main_behind`] exists for (#1457). No
6510    /// `origin/HEAD` symref is set, so resolution goes through
6511    /// [`RemoteInfo::detect_main_branch_local`]'s common-names fallback.
6512    fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6513        let repo = init_repo(dir);
6514        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6515        let a_commit = repo.find_commit(a).unwrap();
6516        let c = empty_commit(&repo, None, &[&a_commit], "C");
6517        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6518            .unwrap();
6519        drop(a_commit);
6520        repo.set_head("refs/heads/main").unwrap();
6521        repo
6522    }
6523
6524    #[test]
6525    fn git_status_reads_branch_and_ahead_behind() {
6526        let dir = tempfile::tempdir().unwrap();
6527        let _repo = diverging_repo(dir.path());
6528        let status = git_status(dir.path());
6529        assert_eq!(status.branch.as_deref(), Some("main"));
6530        assert_eq!(status.ahead, Some(1));
6531        assert_eq!(status.behind, Some(1));
6532        // A normal checkout names itself and is not flagged a worktree.
6533        assert_eq!(
6534            status.main_repo.as_deref(),
6535            dir.path().file_name().and_then(|n| n.to_str())
6536        );
6537        assert!(!status.is_worktree);
6538    }
6539
6540    #[test]
6541    fn git_status_empty_repo_is_unborn() {
6542        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6543        // branch/sync fields stay empty rather than panicking — but the repo
6544        // identity is still resolved from the common dir.
6545        let dir = tempfile::tempdir().unwrap();
6546        init_repo(dir.path());
6547        let status = git_status(dir.path());
6548        assert_eq!(status.branch, None);
6549        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6550        assert_eq!(status.head_sha, None);
6551        assert_eq!(status.ahead, None);
6552        assert_eq!(status.behind, None);
6553        assert_eq!(
6554            status.main_repo.as_deref(),
6555            dir.path().file_name().and_then(|n| n.to_str())
6556        );
6557        assert!(!status.is_worktree);
6558    }
6559
6560    #[test]
6561    fn git_status_no_upstream_reports_branch_only() {
6562        let dir = tempfile::tempdir().unwrap();
6563        let repo = init_repo(dir.path());
6564        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6565        repo.set_head("refs/heads/main").unwrap();
6566        let status = git_status(dir.path());
6567        assert_eq!(status.branch.as_deref(), Some("main"));
6568        // No upstream → ahead/behind stay absent rather than zero.
6569        assert_eq!(status.ahead, None);
6570        assert_eq!(status.behind, None);
6571        // …and so does the upstream SHA, so such a branch still renders with no
6572        // sync indicator at all (#1344).
6573        assert_eq!(status.upstream_sha, None);
6574    }
6575
6576    #[test]
6577    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6578        // A plain directory that is not a git repo yields nothing at all.
6579        let plain = tempfile::tempdir().unwrap();
6580        assert_eq!(git_status(plain.path()), GitStatus::default());
6581
6582        // A detached HEAD reports no branch (and thus no sync), but the repo
6583        // identity is still resolved from the common dir.
6584        let dir = tempfile::tempdir().unwrap();
6585        let repo = init_repo(dir.path());
6586        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6587        repo.set_head_detached(a).unwrap();
6588        let status = git_status(dir.path());
6589        assert_eq!(status.branch, None);
6590        // A detached HEAD has no branch but *does* have a commit — the SHA is
6591        // resolved before the branch filter, so it survives here (#1337).
6592        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6593        assert_eq!(status.ahead, None);
6594        assert_eq!(status.behind, None);
6595        // A detached HEAD has no branch, so there is no upstream to resolve
6596        // either — the branch filter returns before the wrap (#1344).
6597        assert_eq!(status.upstream_sha, None);
6598        assert_eq!(
6599            status.main_repo.as_deref(),
6600            dir.path().file_name().and_then(|n| n.to_str())
6601        );
6602        assert!(!status.is_worktree);
6603    }
6604
6605    // --- Lazy ahead/behind (#1306) -----------------------------------------
6606
6607    #[test]
6608    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6609        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6610        // the streamed tree snapshot still reads the branch and repo identity, but
6611        // leaves ahead/behind absent — divergence is now lazy (#1306).
6612        let dir = tempfile::tempdir().unwrap();
6613        let repo = diverging_repo(dir.path());
6614        let status = git_status_cheap(dir.path());
6615        assert_eq!(status.branch.as_deref(), Some("main"));
6616        assert_eq!(status.ahead, None);
6617        assert_eq!(status.behind, None);
6618        assert_eq!(
6619            status.main_repo.as_deref(),
6620            dir.path().file_name().and_then(|n| n.to_str())
6621        );
6622        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6623        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6624        let head = repo.head().unwrap().target().unwrap();
6625        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6626    }
6627
6628    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6629
6630    #[test]
6631    fn git_status_head_sha_tracks_new_commits() {
6632        // The regression #1337 turns on: a commit must change the status the
6633        // snapshot is built from. Before the SHA rode the payload, committing
6634        // changed nothing on the wire, the server's diff dropped the identical
6635        // snapshot, and no client re-rendered — so a badge computed for the old
6636        // head survived the push that invalidated it.
6637        let dir = tempfile::tempdir().unwrap();
6638        let repo = init_repo(dir.path());
6639        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6640        repo.set_head("refs/heads/main").unwrap();
6641        let before = git_status_cheap(dir.path());
6642        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6643
6644        let head = repo.find_commit(a).unwrap();
6645        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6646        let after = git_status_cheap(dir.path());
6647        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6648        assert_ne!(before.head_sha, after.head_sha);
6649        // The branch is unchanged — the SHA is the *only* thing that moved, which
6650        // is exactly why its absence made the push invisible.
6651        assert_eq!(before.branch, after.branch);
6652    }
6653
6654    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6655
6656    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6657    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6658    /// these tests exercise a push with no network and no second repo.
6659    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6660        repo.reference("refs/remotes/origin/main", oid, true, "push")
6661            .unwrap();
6662    }
6663
6664    #[test]
6665    fn git_status_upstream_sha_tracks_a_push() {
6666        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6667        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6668        // remote-tracking ref, so before this field rode the payload every wire
6669        // field was byte-identical across the push, the server's diff dropped the
6670        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6671        // never re-asked — the row showed `↑1 ↓0` forever.
6672        let dir = tempfile::tempdir().unwrap();
6673        let repo = diverging_repo(dir.path());
6674        let before = git_status(dir.path());
6675        assert_eq!(before.ahead, Some(1));
6676        assert_eq!(before.behind, Some(1));
6677
6678        let head = repo.head().unwrap().target().unwrap();
6679        simulate_push(&repo, head);
6680        let after = git_status(dir.path());
6681
6682        // The upstream now names the pushed commit, and the counts agree.
6683        assert_eq!(
6684            after.upstream_sha.as_deref(),
6685            Some(head.to_string().as_str())
6686        );
6687        assert_ne!(before.upstream_sha, after.upstream_sha);
6688        assert_eq!(after.ahead, Some(0));
6689        assert_eq!(after.behind, Some(0));
6690        // Nothing else moved — which is the whole point. A push leaves the branch
6691        // and the local head exactly where they were, so `upstream_sha` is the
6692        // only signal a client could possibly notice.
6693        assert_eq!(before.branch, after.branch);
6694        assert_eq!(before.head_sha, after.head_sha);
6695    }
6696
6697    #[test]
6698    fn git_status_cheap_reports_upstream_sha() {
6699        // The crux: the field has to ride the *cheap* path, since that is the one
6700        // the streamed snapshot is built from. Costing a config lookup and a refs
6701        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6702        // walk still absent here.
6703        let dir = tempfile::tempdir().unwrap();
6704        let repo = diverging_repo(dir.path());
6705        let status = git_status_cheap(dir.path());
6706        let upstream = repo
6707            .find_branch("origin/main", git2::BranchType::Remote)
6708            .unwrap()
6709            .get()
6710            .target()
6711            .unwrap();
6712        assert_eq!(
6713            status.upstream_sha.as_deref(),
6714            Some(upstream.to_string().as_str())
6715        );
6716        assert_eq!(status.ahead, None);
6717        assert_eq!(status.behind, None);
6718    }
6719
6720    #[test]
6721    fn folder_ahead_behind_computes_divergence_and_degrades() {
6722        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6723        let dir = tempfile::tempdir().unwrap();
6724        let _repo = diverging_repo(dir.path());
6725        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6726
6727        // A branch with no upstream → None (the tree renders no sync indicator).
6728        let no_up = tempfile::tempdir().unwrap();
6729        let repo = init_repo(no_up.path());
6730        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6731        repo.set_head("refs/heads/main").unwrap();
6732        assert_eq!(folder_ahead_behind(no_up.path()), None);
6733
6734        // A detached HEAD and a plain (non-repo) directory → None.
6735        let detached = tempfile::tempdir().unwrap();
6736        let drepo = init_repo(detached.path());
6737        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6738        drepo.set_head_detached(a).unwrap();
6739        assert_eq!(folder_ahead_behind(detached.path()), None);
6740        let plain = tempfile::tempdir().unwrap();
6741        assert_eq!(folder_ahead_behind(plain.path()), None);
6742    }
6743
6744    // --- Lazy main-branch behind (#1457) ------------------------------------
6745
6746    #[test]
6747    fn folder_main_behind_computes_divergence_and_degrades() {
6748        // No own upstream, but a resolvable `origin/main` (via the common-names
6749        // fallback — no `origin/HEAD` symref is set) that the branch is
6750        // genuinely behind.
6751        let dir = tempfile::tempdir().unwrap();
6752        let _repo = behind_main_no_upstream_repo(dir.path());
6753        assert_eq!(folder_main_behind(dir.path()), Some(1));
6754
6755        // A detached HEAD and a plain (non-repo) directory → None.
6756        let detached = tempfile::tempdir().unwrap();
6757        let drepo = init_repo(detached.path());
6758        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6759        drepo.set_head_detached(a).unwrap();
6760        assert_eq!(folder_main_behind(detached.path()), None);
6761        let plain = tempfile::tempdir().unwrap();
6762        assert_eq!(folder_main_behind(plain.path()), None);
6763    }
6764
6765    #[test]
6766    fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6767        // `diverging_repo` checks out `main` tracking `origin/main` itself — the
6768        // common case — so even though it's genuinely 1 behind, `folder_main_behind`
6769        // stays silent: `folder_ahead_behind`'s `behind` already reports this
6770        // exact divergence.
6771        let dir = tempfile::tempdir().unwrap();
6772        let _repo = diverging_repo(dir.path());
6773        assert_eq!(folder_main_behind(dir.path()), None);
6774    }
6775
6776    #[test]
6777    fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6778        // No `origin` remote-tracking refs at all (no symref, no common names).
6779        let dir = tempfile::tempdir().unwrap();
6780        let repo = init_repo(dir.path());
6781        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6782        repo.set_head("refs/heads/main").unwrap();
6783        assert_eq!(folder_main_behind(dir.path()), None);
6784    }
6785
6786    #[test]
6787    fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6788        let dir = tempfile::tempdir().unwrap();
6789        let repo = init_repo(dir.path());
6790        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6791        let base_commit = repo.find_commit(base).unwrap();
6792
6793        // origin/main advances 3 commits past the shared base.
6794        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6795        let m1_commit = repo.find_commit(m1).unwrap();
6796        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6797        let m2_commit = repo.find_commit(m2).unwrap();
6798        let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6799        repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6800            .unwrap();
6801
6802        // `feature` branches off the shared base and diverges 1 ahead / 1
6803        // behind its own upstream `origin/feature`.
6804        let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6805        repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6806            .unwrap();
6807        empty_commit(
6808            &repo,
6809            Some("refs/heads/feature"),
6810            &[&base_commit],
6811            "local-feature",
6812        );
6813        drop(base_commit);
6814        drop(m1_commit);
6815        drop(m2_commit);
6816
6817        repo.set_head("refs/heads/feature").unwrap();
6818        let mut cfg = repo.config().unwrap();
6819        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6820            .unwrap();
6821        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6822            .unwrap();
6823        cfg.set_str("branch.feature.remote", "origin").unwrap();
6824        cfg.set_str("branch.feature.merge", "refs/heads/feature")
6825            .unwrap();
6826
6827        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6828        assert_eq!(folder_main_behind(dir.path()), Some(3));
6829    }
6830
6831    #[tokio::test]
6832    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6833        let diverging = tempfile::tempdir().unwrap();
6834        let _d = diverging_repo(diverging.path());
6835        let no_up = tempfile::tempdir().unwrap();
6836        let repo = init_repo(no_up.path());
6837        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6838        repo.set_head("refs/heads/main").unwrap();
6839
6840        let svc = WorktreesService::new();
6841        let diverging_path = diverging.path().display().to_string();
6842        let no_up_path = no_up.path().display().to_string();
6843        let reply = svc
6844            .handle(
6845                "ahead-behind",
6846                json!({ "paths": [&diverging_path, &no_up_path] }),
6847            )
6848            .await
6849            .unwrap();
6850        let results = reply.get("results").unwrap();
6851        // The diverging worktree carries its counts, keyed by the requested path.
6852        // `diverging_repo` checks out `main` tracking `origin/main` itself, so
6853        // `main_behind` is skipped — `behind` already reports this divergence.
6854        let d = results.get(diverging_path.as_str()).unwrap();
6855        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6856        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6857        assert!(d.get("main_behind").is_none(), "{d:?}");
6858        // The no-upstream worktree has no `origin` remote-tracking refs at all,
6859        // so neither `ahead`/`behind` nor `main_behind` resolves — the row is
6860        // omitted entirely, not reported as zero.
6861        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6862
6863        // A missing/empty `paths` list yields an empty results object, not an error.
6864        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6865        assert_eq!(empty.get("results"), Some(&json!({})));
6866    }
6867
6868    #[tokio::test]
6869    async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6870        let dir = tempfile::tempdir().unwrap();
6871        let _repo = behind_main_no_upstream_repo(dir.path());
6872        let svc = WorktreesService::new();
6873        let path = dir.path().display().to_string();
6874        let reply = svc
6875            .handle("ahead-behind", json!({ "paths": [&path] }))
6876            .await
6877            .unwrap();
6878        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6879        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6880        // No own upstream at all → no `ahead`/`behind` keys, just `main_behind`.
6881        assert!(entry.get("ahead").is_none(), "{entry:?}");
6882        assert!(entry.get("behind").is_none(), "{entry:?}");
6883    }
6884
6885    #[tokio::test]
6886    async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6887        // `release` stays perfectly in sync with its own upstream
6888        // `origin/release`, while `origin/main` has independently advanced 2
6889        // commits past their shared base — `main_behind` must still fold in
6890        // even though `ahead`/`behind` are both zero.
6891        let dir = tempfile::tempdir().unwrap();
6892        let repo = init_repo(dir.path());
6893        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6894        let base_commit = repo.find_commit(base).unwrap();
6895
6896        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6897        let m1_commit = repo.find_commit(m1).unwrap();
6898        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6899        repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6900            .unwrap();
6901
6902        let r = empty_commit(
6903            &repo,
6904            Some("refs/heads/release"),
6905            &[&base_commit],
6906            "release",
6907        );
6908        repo.reference("refs/remotes/origin/release", r, true, "origin release")
6909            .unwrap();
6910        drop(base_commit);
6911        drop(m1_commit);
6912
6913        repo.set_head("refs/heads/release").unwrap();
6914        let mut cfg = repo.config().unwrap();
6915        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6916            .unwrap();
6917        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6918            .unwrap();
6919        cfg.set_str("branch.release.remote", "origin").unwrap();
6920        cfg.set_str("branch.release.merge", "refs/heads/release")
6921            .unwrap();
6922
6923        let svc = WorktreesService::new();
6924        let path = dir.path().display().to_string();
6925        let reply = svc
6926            .handle("ahead-behind", json!({ "paths": [&path] }))
6927            .await
6928            .unwrap();
6929        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6930        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6931        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6932        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6933    }
6934
6935    #[tokio::test]
6936    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6937        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
6938        let dir = tempfile::tempdir().unwrap();
6939        let _repo = diverging_repo(dir.path());
6940        let svc = WorktreesService::new();
6941        svc.handle(
6942            "register",
6943            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6944        )
6945        .await
6946        .unwrap();
6947
6948        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6949        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6950        let main_wt = &worktrees[0];
6951        // The cheap parts are present, but divergence is not — it is fetched
6952        // lazily via the `ahead-behind` op (#1306).
6953        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6954        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6955        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6956    }
6957
6958    #[tokio::test]
6959    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6960        // The end-to-end shape of the #1337 freshness fix. The server pushes a
6961        // snapshot only when it differs from the last one
6962        // (`server.rs`: `if snap != last`), so anything invisible on the wire
6963        // cannot trigger a re-render. Committing must therefore move the payload.
6964        let dir = tempfile::tempdir().unwrap();
6965        let repo = init_repo(dir.path());
6966        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6967        repo.set_head("refs/heads/main").unwrap();
6968
6969        let svc = WorktreesService::new();
6970        svc.handle(
6971            "register",
6972            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6973        )
6974        .await
6975        .unwrap();
6976
6977        let before = svc.handle("tree", Value::Null).await.unwrap();
6978        let wt = &repos_of(&before)[0]["worktrees"][0];
6979        assert_eq!(
6980            wt.get("head_sha").and_then(Value::as_str),
6981            Some(a.to_string().as_str())
6982        );
6983
6984        // Commit again: same branch, same paths, same open windows — pre-#1337 the
6985        // snapshot was byte-identical here and the push was dropped.
6986        let head = repo.find_commit(a).unwrap();
6987        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6988        let after = svc.handle("tree", Value::Null).await.unwrap();
6989        assert_eq!(
6990            repos_of(&after)[0]["worktrees"][0]
6991                .get("head_sha")
6992                .and_then(Value::as_str),
6993            Some(b.to_string().as_str())
6994        );
6995        assert_ne!(before, after, "a commit must be a visible snapshot delta");
6996    }
6997
6998    #[tokio::test]
6999    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7000        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
7001        // matching the payload's `skip_serializing_if` convention.
7002        let dir = tempfile::tempdir().unwrap();
7003        init_repo(dir.path());
7004        let svc = WorktreesService::new();
7005        svc.handle(
7006            "register",
7007            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7008        )
7009        .await
7010        .unwrap();
7011        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7012        assert!(wt.get("head_sha").is_none(), "{wt:?}");
7013    }
7014
7015    // --- Upstream SHA on the snapshot (#1344) ------------------------------
7016
7017    #[tokio::test]
7018    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7019        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
7020        // moves neither the branch nor the local head, so `upstream_sha` is the
7021        // only field that can carry the news. Without it the snapshot serialised
7022        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
7023        // window re-rendered, and the lazy ahead/behind was never re-fetched.
7024        let dir = tempfile::tempdir().unwrap();
7025        let repo = diverging_repo(dir.path());
7026        let svc = WorktreesService::new();
7027        svc.handle(
7028            "register",
7029            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7030        )
7031        .await
7032        .unwrap();
7033
7034        let before = svc.handle("tree", Value::Null).await.unwrap();
7035        let head = repo.head().unwrap().target().unwrap();
7036        assert_ne!(
7037            repos_of(&before)[0]["worktrees"][0]
7038                .get("upstream_sha")
7039                .and_then(Value::as_str),
7040            Some(head.to_string().as_str()),
7041            "the fixture must start un-pushed for this to prove anything"
7042        );
7043
7044        // Push: only `refs/remotes/origin/main` moves.
7045        simulate_push(&repo, head);
7046        let after = svc.handle("tree", Value::Null).await.unwrap();
7047        let wt = &repos_of(&after)[0]["worktrees"][0];
7048        assert_eq!(
7049            wt.get("upstream_sha").and_then(Value::as_str),
7050            Some(head.to_string().as_str())
7051        );
7052        // The head and branch are untouched across the push — so this delta rests
7053        // entirely on `upstream_sha`.
7054        assert_eq!(
7055            wt.get("head_sha").and_then(Value::as_str),
7056            repos_of(&before)[0]["worktrees"][0]
7057                .get("head_sha")
7058                .and_then(Value::as_str)
7059        );
7060        assert_ne!(before, after, "a push must be a visible snapshot delta");
7061    }
7062
7063    #[tokio::test]
7064    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7065        // Wire-compat, and the no-regression case: a branch tracking nothing sends
7066        // no key at all rather than a null, so an older client sees exactly the
7067        // payload it saw before and still renders no sync indicator.
7068        let dir = tempfile::tempdir().unwrap();
7069        let repo = init_repo(dir.path());
7070        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7071        repo.set_head("refs/heads/main").unwrap();
7072        let svc = WorktreesService::new();
7073        svc.handle(
7074            "register",
7075            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7076        )
7077        .await
7078        .unwrap();
7079        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7080        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7081        // The head still rides, so this is specifically the upstream degrading.
7082        assert!(wt.get("head_sha").is_some(), "{wt:?}");
7083    }
7084
7085    // --- PR badge poller (#1337) -------------------------------------------
7086
7087    /// Writes an executable stub that ignores its arguments and prints `stdout`,
7088    /// standing in for `gh api graphql` so the poll loop is exercised offline.
7089    /// Returns the shim lock alongside the path: the caller **must** hold the
7090    /// guard until the poller has finished exec'ing the stub. Writing an
7091    /// executable and then `execve`ing it races every other thread that forks —
7092    /// the child inherits the still-open writable FD and the exec fails
7093    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
7094    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7095        let guard = shim_lock();
7096        let path = dir.join("fake-gh");
7097        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7098        (path, guard)
7099    }
7100
7101    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
7102    /// byte to a counter file before printing `stdout`. The returned counter path
7103    /// lets a test assert how many `gh` subprocesses actually ran, independent of
7104    /// the #1387 request-log counter — so the two can be compared (#1389).
7105    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7106        let guard = shim_lock();
7107        let path = dir.join("fake-gh");
7108        let counter = dir.join("gh-calls");
7109        write_exec_script(
7110            &path,
7111            &format!(
7112                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7113                counter = counter.display()
7114            ),
7115        );
7116        (path, guard, counter)
7117    }
7118
7119    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
7120    /// ran) — the length of the counter file.
7121    fn gh_spawn_count(counter: &Path) -> usize {
7122        std::fs::read(counter).map_or(0, |b| b.len())
7123    }
7124
7125    /// The number of **successful** `kind: "gh"` records the #1387 choke point
7126    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
7127    /// on the exit code matches the ground-truth counter (which is written when the
7128    /// stub *runs*), so a rare failed spawn cannot desync the two.
7129    fn counted_gh_records(log: &Path) -> usize {
7130        std::fs::read_to_string(log)
7131            .unwrap_or_default()
7132            .lines()
7133            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7134            .count()
7135    }
7136
7137    /// A repo with a GitHub origin and one commit on `main`.
7138    fn github_repo(dir: &Path) -> Repository {
7139        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7140    }
7141
7142    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
7143    /// test can register **distinct** targets (owner/name) across windows.
7144    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7145        let repo = init_repo(dir);
7146        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7147        repo.set_head("refs/heads/main").unwrap();
7148        repo.remote("origin", url).unwrap();
7149        repo
7150    }
7151
7152    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
7153    /// because the fold downgrades a badge naming a different commit than the
7154    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
7155    /// wrong reason.
7156    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7157        PrBadge {
7158            number,
7159            is_draft: false,
7160            checks: PrCheckState::Pending,
7161            url: "u".into(),
7162            head_oid: head_oid.to_string(),
7163        }
7164    }
7165
7166    /// Wraps a badge as the cache's resolution value (#1370).
7167    fn pr(badge: PrBadge) -> PrResolution {
7168        PrResolution::Pr(badge)
7169    }
7170
7171    #[test]
7172    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7173        let snapshot = json!({"repos":[
7174            {
7175                "main_repo":"omni-dev",
7176                "github":{"owner":"rust-works","name":"omni-dev"},
7177                "root":"/r",
7178                // Enabled (#1376) — a not-polled repo contributes no targets (see
7179                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
7180                "polling_enabled":true,
7181                // Two worktrees on the same branch must ask once, not twice.
7182                "worktrees":[
7183                    {"path":"/r","branch":"main","is_main":true,"open":true},
7184                    {"path":"/w1","branch":"main","is_main":false,"open":true},
7185                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
7186                    // Detached: no branch, so nothing to resolve.
7187                    {"path":"/w3","is_main":false,"open":true}
7188                ]
7189            },
7190            {
7191                // Not on GitHub: contributes no targets at all.
7192                "main_repo":"local","root":"/l",
7193                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7194            }
7195        ]});
7196        let targets = pr_targets_from_snapshot(&snapshot);
7197        assert_eq!(
7198            targets,
7199            vec![
7200                PrTarget {
7201                    owner: "rust-works".into(),
7202                    name: "omni-dev".into(),
7203                    branch: "feature".into()
7204                },
7205                PrTarget {
7206                    owner: "rust-works".into(),
7207                    name: "omni-dev".into(),
7208                    branch: "main".into()
7209                },
7210            ]
7211        );
7212    }
7213
7214    #[test]
7215    fn pr_targets_from_snapshot_is_empty_without_repos() {
7216        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7217        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7218    }
7219
7220    #[test]
7221    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7222        // Defensive: a `github` object without usable owner/name strings yields no
7223        // target rather than a half-built query.
7224        for github in [
7225            json!({}),
7226            json!({"owner": "o"}),
7227            json!({"owner": 1, "name": 2}),
7228        ] {
7229            let snapshot = json!({"repos":[{
7230                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7231                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7232            }]});
7233            assert!(
7234                pr_targets_from_snapshot(&snapshot).is_empty(),
7235                "{snapshot:?}"
7236            );
7237        }
7238    }
7239
7240    #[test]
7241    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7242        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
7243        // absent (the default-off case) or explicitly false contributes no watch,
7244        // so the poll never mentions it. Only an enabled repo is polled.
7245        for repo in [
7246            json!({
7247                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7248                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7249            }),
7250            json!({
7251                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7252                "polling_enabled":false,
7253                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7254            }),
7255        ] {
7256            let snapshot = json!({ "repos": [repo] });
7257            assert!(
7258                pr_targets_from_snapshot(&snapshot).is_empty(),
7259                "not-polled repo must yield no targets: {snapshot:?}"
7260            );
7261        }
7262        // Flipping the same repo to enabled makes it contribute.
7263        let enabled = json!({"repos":[{
7264            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7265            "polling_enabled":true,
7266            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7267        }]});
7268        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7269    }
7270
7271    #[test]
7272    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7273        let backoff = Duration::from_secs(600);
7274        // Never fetched: go.
7275        assert!(pr_should_fetch(false, None, backoff));
7276        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
7277        // spend nothing.
7278        assert!(!pr_should_fetch(
7279            false,
7280            Some(Duration::from_secs(1)),
7281            backoff
7282        ));
7283        // Quiet tree, backoff elapsed: time to look again.
7284        assert!(pr_should_fetch(false, Some(backoff), backoff));
7285        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7286        // The load-bearing case: the watch grew (a target added, or an upstream
7287        // pushed), so fetch **now** regardless of how deep the backoff had grown.
7288        // Without this a push waits out the full ceiling on a stale badge.
7289        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7290        assert!(pr_should_fetch(
7291            true,
7292            Some(Duration::from_millis(1)),
7293            backoff
7294        ));
7295    }
7296
7297    #[test]
7298    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7299        let base = Duration::from_secs(10);
7300        let fresh = Some(Duration::ZERO);
7301        let stale = Some(PENDING_FAST_WINDOW);
7302        // Pending and fresh (within the fast window): hold `base`, however long we
7303        // had backed off for.
7304        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7305        assert_eq!(
7306            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7307            base
7308        );
7309        // Pending but past the fast window (a long CI run): escalate — double up to
7310        // the pending ceiling, never to the terminal one.
7311        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7312        assert_eq!(
7313            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7314            PENDING_MAX_INTERVAL
7315        );
7316        // Pending with nothing having moved yet (`None`) is treated as past the fast
7317        // window, so a stale-from-boot pending state does not pin `base`.
7318        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7319        // Everything terminal: double…
7320        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7321        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7322        // …up to the terminal ceiling (above the pending one), and never overflow.
7323        assert_eq!(
7324            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7325            MAX_PR_POLL_INTERVAL
7326        );
7327        assert_eq!(
7328            next_pr_poll_delay(Duration::MAX, base, false, None),
7329            MAX_PR_POLL_INTERVAL
7330        );
7331    }
7332
7333    /// A watch on one branch with the given upstream tip.
7334    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7335        PrWatch {
7336            target: PrTarget {
7337                owner: "rust-works".into(),
7338                name: "omni-dev".into(),
7339                branch: branch.into(),
7340            },
7341            upstream_sha: upstream.map(str::to_string),
7342        }
7343    }
7344
7345    #[test]
7346    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7347        let a = watch("a", Some("111"));
7348        let b = watch("b", Some("222"));
7349        let ab = [a.clone(), b.clone()];
7350        let just_a = std::slice::from_ref(&a);
7351        let just_b = std::slice::from_ref(&b);
7352        // Nothing new: quiet tick.
7353        assert!(!pr_watch_grew(&ab, &ab));
7354        // Addition: a new target appeared.
7355        assert!(pr_watch_grew(just_a, &ab));
7356        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
7357        assert!(!pr_watch_grew(&ab, just_a));
7358        // First sight (empty prev, from `None`): everything is new.
7359        assert!(pr_watch_grew(&[], just_a));
7360        // A push moves only the upstream — still "grew".
7361        let a_pushed = [watch("a", Some("999"))];
7362        assert!(pr_watch_grew(just_a, &a_pushed));
7363        // Gaining a target while losing another still fetches (the gain wins).
7364        assert!(pr_watch_grew(just_a, just_b));
7365    }
7366
7367    #[test]
7368    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7369        let base = Duration::from_secs(10);
7370        let over = RateLimitSnapshot {
7371            graphql: Some(rl_resource(90)),
7372            core: Some(rl_resource(3)),
7373            search: None,
7374        };
7375        let under = RateLimitSnapshot {
7376            graphql: Some(rl_resource(50)),
7377            core: Some(rl_resource(3)),
7378            search: None,
7379        };
7380        // No reading yet: unchanged.
7381        assert_eq!(budget_throttled_delay(base, None), base);
7382        // Under the warn threshold: unchanged.
7383        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7384        // Over: raised to at least the throttle floor…
7385        assert_eq!(
7386            budget_throttled_delay(base, Some(&over)),
7387            BUDGET_THROTTLE_INTERVAL
7388        );
7389        // …but a delay already above the floor is left alone (never shortened).
7390        let long = BUDGET_THROTTLE_INTERVAL * 2;
7391        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7392    }
7393
7394    #[test]
7395    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7396        // The persisted cache must survive a JSON round trip with `head_oid` intact
7397        // — it is the staleness key the tree wire drops, and losing it would render
7398        // every restored badge stale (#1389, fix 4).
7399        let target = PrTarget {
7400            owner: "rust-works".into(),
7401            name: "omni-dev".into(),
7402            branch: "main".into(),
7403        };
7404        let badge = PrResolution::Pr(PrBadge {
7405            number: 1337,
7406            is_draft: true,
7407            checks: PrCheckState::Pending,
7408            url: "http://x/1337".into(),
7409            head_oid: "deadbeef".into(),
7410        });
7411        let watched = vec![watch("main", Some("abc"))];
7412        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7413            .unwrap()
7414            .with_timezone(&Utc);
7415        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7416
7417        let json = serde_json::to_vec(&prefs).unwrap();
7418        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7419        assert_eq!(back, prefs);
7420        assert_eq!(back.polled_at, Some(polled_at));
7421        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7422        // The restored resolution equals the original — head_oid and all.
7423        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7424    }
7425
7426    #[test]
7427    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7428        // The explicit negative must survive persistence too: restoring `NoPr`
7429        // as "absent" would lose the #1370 distinction across a restart and
7430        // re-ask GitHub for branches already known to have no PR.
7431        let target = PrTarget {
7432            owner: "rust-works".into(),
7433            name: "omni-dev".into(),
7434            branch: "feature".into(),
7435        };
7436        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7437        let json = serde_json::to_vec(&prefs).unwrap();
7438        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7439        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7440        assert_eq!(
7441            back.entries[0].resolution.clone().into_resolution(),
7442            PrResolution::NoPr
7443        );
7444    }
7445
7446    #[test]
7447    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7448        // A file with verdicts but no `polled_at` (an older shape, or a
7449        // hand-edited one) still renders badges, but must not arm the warm
7450        // start: without a poll time there is nothing to age the verdicts
7451        // against, so the poller re-polls immediately (#1389, fix 4).
7452        let dir = tempfile::tempdir().unwrap();
7453        let path = dir.path().join("pr-cache.json");
7454        let target = PrTarget {
7455            owner: "rust-works".into(),
7456            name: "omni-dev".into(),
7457            branch: "main".into(),
7458        };
7459        let mut prefs = pr_cache_prefs_from(
7460            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7461            &[watch("main", None)],
7462            Utc::now(),
7463        );
7464        prefs.polled_at = None;
7465        write_pr_cache(&path, &prefs).unwrap();
7466
7467        let svc = WorktreesService::new();
7468        svc.load_pr_cache(path);
7469        assert!(
7470            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7471            "the badge itself must still restore"
7472        );
7473        assert!(
7474            svc.pr_warm_start
7475                .lock()
7476                .unwrap_or_else(PoisonError::into_inner)
7477                .is_none(),
7478            "no poll time means a cold start, not a trusted warm one"
7479        );
7480    }
7481
7482    /// Installs a thread-local WARN-level subscriber for the duration of a
7483    /// test, so degraded-path `tracing::warn!` sites actually format their
7484    /// fields instead of short-circuiting on "nobody is listening".
7485    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7486        tracing::subscriber::set_default(
7487            tracing_subscriber::fmt()
7488                .with_max_level(tracing::Level::WARN)
7489                .with_writer(std::io::sink)
7490                .finish(),
7491        )
7492    }
7493
7494    #[test]
7495    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7496        // The best-effort contract: a mangled cache is logged and treated as
7497        // empty — never a panic — and the path is stored regardless, so the
7498        // next successful poll rewrites a clean file (#1389, fix 4).
7499        let _trace = warn_subscriber();
7500        let dir = tempfile::tempdir().unwrap();
7501        let corrupt = dir.path().join("pr-cache.json");
7502        std::fs::write(&corrupt, b"not json").unwrap();
7503        let svc = WorktreesService::new();
7504        svc.load_pr_cache(corrupt.clone());
7505        assert!(svc.pr_cache.entries().is_empty());
7506        assert_eq!(
7507            svc.pr_cache_path
7508                .lock()
7509                .unwrap_or_else(PoisonError::into_inner)
7510                .as_deref(),
7511            Some(corrupt.as_path()),
7512            "the path must be stored even when the load fails, so persistence recovers"
7513        );
7514
7515        // A directory: `read` fails with a non-NotFound error (the distinct
7516        // "could not read" arm), with the same treated-as-empty outcome.
7517        let svc = WorktreesService::new();
7518        svc.load_pr_cache(dir.path().to_path_buf());
7519        assert!(svc.pr_cache.entries().is_empty());
7520    }
7521
7522    #[test]
7523    fn persist_pr_cache_swallows_a_write_failure() {
7524        // Best-effort: an unwritable path is logged at WARN and swallowed — the
7525        // in-memory cache stays authoritative, and losing the warm start only
7526        // costs one extra poll after the next restart.
7527        let _trace = warn_subscriber();
7528        let dir = tempfile::tempdir().unwrap();
7529        let blocker = dir.path().join("blocker");
7530        std::fs::write(&blocker, b"").unwrap();
7531        // The parent is a regular file, so creating the runtime dir must fail.
7532        let path = blocker.join("pr-cache.json");
7533        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7534        assert!(!path.exists());
7535        // The root has no parent at all — nothing to create, and the write
7536        // itself fails (it is a directory); still swallowed.
7537        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7538    }
7539
7540    #[tokio::test]
7541    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7542        let dir = tempfile::tempdir().unwrap();
7543        let repo = github_repo(dir.path());
7544        let head = repo.head().unwrap().target().unwrap().to_string();
7545        let svc = WorktreesService::new();
7546        svc.handle(
7547            "register",
7548            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7549        )
7550        .await
7551        .unwrap();
7552        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7553        // act on it, as if the user had toggled it on.
7554        svc.registry.set_polling("rust-works", "omni-dev", true);
7555
7556        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
7557        // and so is the negative, so "not resolved" stays distinguishable (#1370).
7558        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7559        assert!(wt.get("pr").is_none(), "{wt:?}");
7560        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7561
7562        // Seed the cache the poller writes, then re-read the tree.
7563        let mut badges = HashMap::new();
7564        badges.insert(
7565            PrTarget {
7566                owner: "rust-works".into(),
7567                name: "omni-dev".into(),
7568                branch: "main".into(),
7569            },
7570            pr(pending_badge(1337, &head)),
7571        );
7572        assert!(svc.pr_cache.replace(badges));
7573
7574        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7575        assert_eq!(wt["pr"]["number"], json!(1337));
7576        assert_eq!(wt["pr"]["checks"], json!("pending"));
7577        // camelCase on the wire, or the extension silently loses the draft marker.
7578        assert_eq!(wt["pr"]["isDraft"], json!(false));
7579        // A badge and the negative are mutually exclusive.
7580        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7581    }
7582
7583    #[tokio::test]
7584    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7585        // A detached HEAD has a commit but no branch, and a badge is keyed by
7586        // branch — so there is nothing to match. It must fall through silently
7587        // rather than borrow a badge from whatever branch happens to be cached, and
7588        // rather than sink the tree.
7589        let dir = tempfile::tempdir().unwrap();
7590        let repo = github_repo(dir.path());
7591        let head = repo.head().unwrap().target().unwrap();
7592        repo.set_head_detached(head).unwrap();
7593
7594        let svc = WorktreesService::new();
7595        svc.handle(
7596            "register",
7597            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7598        )
7599        .await
7600        .unwrap();
7601        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7602        // act on it, as if the user had toggled it on.
7603        svc.registry.set_polling("rust-works", "omni-dev", true);
7604        // A badge *is* cached for `main` — the branch this worktree was on before
7605        // detaching. It must not leak onto the now-branchless row.
7606        let mut badges = HashMap::new();
7607        badges.insert(
7608            PrTarget {
7609                owner: "rust-works".into(),
7610                name: "omni-dev".into(),
7611                branch: "main".into(),
7612            },
7613            pr(pending_badge(1, &head.to_string())),
7614        );
7615        svc.pr_cache.replace(badges);
7616
7617        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7618        assert!(wt.get("branch").is_none(), "{wt:?}");
7619        // The SHA still shows — detached means no branch, not no commit.
7620        assert_eq!(
7621            wt.get("head_sha").and_then(Value::as_str),
7622            Some(head.to_string().as_str())
7623        );
7624        assert!(wt.get("pr").is_none(), "{wt:?}");
7625        // No branch means nothing was checked either: no negative on the row.
7626        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7627    }
7628
7629    #[tokio::test]
7630    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7631        let dir = tempfile::tempdir().unwrap();
7632        github_repo(dir.path());
7633        let svc = WorktreesService::new();
7634        svc.handle(
7635            "register",
7636            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7637        )
7638        .await
7639        .unwrap();
7640        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7641        // act on it, as if the user had toggled it on.
7642        svc.registry.set_polling("rust-works", "omni-dev", true);
7643        // A badge for a different branch must not leak onto `main`.
7644        let mut badges = HashMap::new();
7645        badges.insert(
7646            PrTarget {
7647                owner: "rust-works".into(),
7648                name: "omni-dev".into(),
7649                branch: "other".into(),
7650            },
7651            pr(pending_badge(1, "irrelevant")),
7652        );
7653        svc.pr_cache.replace(badges);
7654        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7655        assert!(wt.get("pr").is_none(), "{wt:?}");
7656        // An unmatched branch is unresolved, not negative.
7657        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7658    }
7659
7660    #[tokio::test]
7661    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7662        // The #1370 fix on the wire: a branch the poller checked and found PR-less
7663        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
7664        // tell "checked, none" from "not resolved" and keep its fallback quiet.
7665        let dir = tempfile::tempdir().unwrap();
7666        github_repo(dir.path());
7667        let svc = WorktreesService::new();
7668        svc.handle(
7669            "register",
7670            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7671        )
7672        .await
7673        .unwrap();
7674        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7675        // act on it, as if the user had toggled it on.
7676        svc.registry.set_polling("rust-works", "omni-dev", true);
7677
7678        let mut resolutions = HashMap::new();
7679        resolutions.insert(
7680            PrTarget {
7681                owner: "rust-works".into(),
7682                name: "omni-dev".into(),
7683                branch: "main".into(),
7684            },
7685            PrResolution::NoPr,
7686        );
7687        assert!(svc.pr_cache.replace(resolutions));
7688
7689        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7690        assert_eq!(wt["pr_none"], json!(true));
7691        // Mutually exclusive with a badge.
7692        assert!(wt.get("pr").is_none(), "{wt:?}");
7693    }
7694
7695    #[tokio::test]
7696    async fn a_commit_does_not_drop_a_negative_resolution() {
7697        // A negative has no commit to be stale against. Dropping it when HEAD
7698        // moves would flip the row back to "unresolved" on every local commit —
7699        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
7700        // poller's `moved` trigger re-checks the branch promptly instead.
7701        let dir = tempfile::tempdir().unwrap();
7702        let repo = github_repo(dir.path());
7703        let first = repo.head().unwrap().target().unwrap();
7704
7705        let svc = WorktreesService::new();
7706        svc.handle(
7707            "register",
7708            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7709        )
7710        .await
7711        .unwrap();
7712        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7713        // act on it, as if the user had toggled it on.
7714        svc.registry.set_polling("rust-works", "omni-dev", true);
7715
7716        let mut resolutions = HashMap::new();
7717        resolutions.insert(
7718            PrTarget {
7719                owner: "rust-works".into(),
7720                name: "omni-dev".into(),
7721                branch: "main".into(),
7722            },
7723            PrResolution::NoPr,
7724        );
7725        svc.pr_cache.replace(resolutions);
7726
7727        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7728        assert_eq!(wt["pr_none"], json!(true));
7729
7730        // Commit — as a push would leave things, with the cache untouched.
7731        let head = repo.find_commit(first).unwrap();
7732        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7733
7734        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7735        assert_eq!(
7736            wt["pr_none"],
7737            json!(true),
7738            "a local commit must not drop the negative"
7739        );
7740    }
7741
7742    #[tokio::test]
7743    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7744        // The idle case — the daemon runs all day with no editor open. Point it at a
7745        // stub that fails loudly if ever spawned: a poll here would both waste
7746        // GitHub budget and, on a real `gh`, wake the radio for nothing.
7747        let bin_dir = tempfile::tempdir().unwrap();
7748        let marker = bin_dir.path().join("spawned");
7749        let fake = bin_dir.path().join("fake-gh");
7750        std::fs::write(
7751            &fake,
7752            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7753        )
7754        .unwrap();
7755        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7756        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7757        std::fs::set_permissions(&fake, perms).unwrap();
7758
7759        let svc = WorktreesService::new();
7760        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7761        tokio::time::sleep(Duration::from_millis(200)).await;
7762        svc.shutdown().await;
7763        assert!(
7764            !marker.exists(),
7765            "the poller must not spawn gh with no windows registered"
7766        );
7767    }
7768
7769    #[tokio::test]
7770    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7771        // Badges are decoration: an unauthenticated or broken `gh` must never sink
7772        // the tree, and one bad poll must not blank rows that were fine a second ago.
7773        let dir = tempfile::tempdir().unwrap();
7774        let repo = github_repo(dir.path());
7775        let head = repo.head().unwrap().target().unwrap().to_string();
7776        let bin_dir = tempfile::tempdir().unwrap();
7777        let fake = bin_dir.path().join("fake-gh");
7778        // Exits non-zero, exactly as `gh` does without `gh auth login`.
7779        std::fs::write(
7780            &fake,
7781            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7782        )
7783        .unwrap();
7784        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7785        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7786        std::fs::set_permissions(&fake, perms).unwrap();
7787
7788        let svc = WorktreesService::new();
7789        svc.handle(
7790            "register",
7791            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7792        )
7793        .await
7794        .unwrap();
7795        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7796        // act on it, as if the user had toggled it on.
7797        svc.registry.set_polling("rust-works", "omni-dev", true);
7798        // Seed a badge as though an earlier poll had succeeded.
7799        let mut seeded = HashMap::new();
7800        seeded.insert(
7801            PrTarget {
7802                owner: "rust-works".into(),
7803                name: "omni-dev".into(),
7804                branch: "main".into(),
7805            },
7806            pr(pending_badge(7, &head)),
7807        );
7808        svc.pr_cache.replace(seeded);
7809
7810        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7811        tokio::time::sleep(Duration::from_millis(200)).await;
7812
7813        // The tree still serves, and the seeded badge survived the failing polls —
7814        // which also minted no false "no PR" negatives (#1370).
7815        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7816        assert_eq!(wt["pr"]["number"], json!(7));
7817        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7818        svc.shutdown().await;
7819    }
7820
7821    #[tokio::test]
7822    // The shim guard is deliberately held across the awaits below: it must span
7823    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
7824    // is against another test writing while this one forks. Safe here — only test
7825    // threads take it, never a task inside the runtime, so it cannot deadlock.
7826    // Scoped per-test rather than on the module, which would also silence the
7827    // registry lock's "never held across .await" invariant.
7828    #[allow(clippy::await_holding_lock)]
7829    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7830        // The normal startup order: the daemon starts at login, *before* any
7831        // editor. It therefore sees an empty tree and backs off to the 30-minute
7832        // ceiling — so unless a register wakes it, the first badge of the session
7833        // arrives up to half an hour after the window does, which reads as the
7834        // feature being broken rather than slow.
7835        let dir = tempfile::tempdir().unwrap();
7836        github_repo(dir.path());
7837        let bin_dir = tempfile::tempdir().unwrap();
7838        let (fake, _shim) = fake_gh(
7839            bin_dir.path(),
7840            r#"{"data":{"r0":{"b0":{
7841                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7842                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7843                ]}}},
7844                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7845            }}}}"#,
7846        );
7847
7848        let svc = WorktreesService::new();
7849        // Poller first, with nothing registered — it backs off on the empty tree.
7850        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7851        tokio::time::sleep(Duration::from_millis(150)).await;
7852
7853        // Now an editor opens.
7854        svc.handle(
7855            "register",
7856            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7857        )
7858        .await
7859        .unwrap();
7860        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7861        // act on it, as if the user had toggled it on.
7862        svc.registry.set_polling("rust-works", "omni-dev", true);
7863
7864        // The badge must follow promptly — the register wakes the loop out of its
7865        // backoff. The deadline is orders of magnitude below the ceiling, so this
7866        // fails on the bug rather than merely being slow.
7867        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7868            loop {
7869                if let Some(PrResolution::Pr(badge)) =
7870                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7871                {
7872                    return badge;
7873                }
7874                tokio::time::sleep(Duration::from_millis(25)).await;
7875            }
7876        })
7877        .await
7878        .expect("a window opening must wake the poller out of its idle backoff");
7879        assert_eq!(badge.number, 99);
7880        svc.shutdown().await;
7881    }
7882
7883    #[tokio::test]
7884    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7885        // The acceptance criterion: "pushing a new commit invalidates the badge
7886        // rather than leaving the previous head's verdict standing."
7887        //
7888        // The cache still holds the verdict for the *old* commit, and the poller may
7889        // have backed off for up to half an hour. So the fold — which runs on every
7890        // snapshot — has to notice on its own, with no network call.
7891        let dir = tempfile::tempdir().unwrap();
7892        let repo = github_repo(dir.path());
7893        let first = repo.head().unwrap().target().unwrap();
7894
7895        let svc = WorktreesService::new();
7896        svc.handle(
7897            "register",
7898            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7899        )
7900        .await
7901        .unwrap();
7902        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7903        // act on it, as if the user had toggled it on.
7904        svc.registry.set_polling("rust-works", "omni-dev", true);
7905
7906        // A green verdict, correctly describing the commit currently checked out.
7907        let mut badges = HashMap::new();
7908        badges.insert(
7909            PrTarget {
7910                owner: "rust-works".into(),
7911                name: "omni-dev".into(),
7912                branch: "main".into(),
7913            },
7914            pr(PrBadge {
7915                number: 1337,
7916                is_draft: false,
7917                checks: PrCheckState::Success,
7918                url: "u".into(),
7919                head_oid: first.to_string(),
7920            }),
7921        );
7922        svc.pr_cache.replace(badges);
7923
7924        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7925        assert_eq!(
7926            wt["pr"]["checks"],
7927            json!("success"),
7928            "green for its own commit"
7929        );
7930
7931        // Now commit — as a push would leave things, with the cache untouched.
7932        let head = repo.find_commit(first).unwrap();
7933        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7934
7935        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7936        assert_eq!(
7937            wt["pr"]["checks"],
7938            json!("pending"),
7939            "the previous commit's ✓ must not stand after a new commit"
7940        );
7941        // The PR itself is still shown — it is the *verdict* that is unknown, not
7942        // the PR.
7943        assert_eq!(wt["pr"]["number"], json!(1337));
7944    }
7945
7946    #[test]
7947    fn is_stale_for_compares_the_commit_the_verdict_describes() {
7948        let badge = pending_badge(1, "aaa");
7949        assert!(!badge.is_stale_for(Some("aaa")));
7950        assert!(badge.is_stale_for(Some("bbb")));
7951        // No local HEAD (unborn): nothing to compare against, so not stale.
7952        assert!(!badge.is_stale_for(None));
7953    }
7954
7955    #[test]
7956    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7957        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
7958        // it — so asking would return exactly the cached verdict, and the badge
7959        // stays correctly stale via `is_stale_for` with no network. So a snapshot
7960        // that differs *only* in `head_sha` must compare **equal** as a watch.
7961        let snap = |sha: &str| {
7962            json!({"repos":[{
7963                "main_repo":"omni-dev",
7964                "github":{"owner":"rust-works","name":"omni-dev"},
7965                "root":"/r",
7966                "polling_enabled":true,
7967                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7968            }]})
7969        };
7970        let before = pr_watch_from_snapshot(&snap("aaa"));
7971        let after = pr_watch_from_snapshot(&snap("bbb"));
7972        assert_eq!(before.len(), 1);
7973        assert_eq!(before[0].target, after[0].target);
7974        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
7975        assert_eq!(before, after);
7976        assert!(!pr_watch_grew(&before, &after));
7977    }
7978
7979    #[test]
7980    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7981        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
7982        // it moves no local head — so an upstream move alone must register as "go
7983        // and ask now", or the badge sits at `●` until the backoff elapses.
7984        let snap = |upstream: &str| {
7985            json!({"repos":[{
7986                "main_repo":"omni-dev",
7987                "github":{"owner":"rust-works","name":"omni-dev"},
7988                "root":"/r",
7989                "polling_enabled":true,
7990                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7991                              "upstream_sha":upstream,"is_main":true,"open":true}]
7992            }]})
7993        };
7994        let before = pr_watch_from_snapshot(&snap("aaa"));
7995        let after = pr_watch_from_snapshot(&snap("bbb"));
7996        // Same target, only the upstream moved — a genuine "grew" signal.
7997        assert_eq!(before.len(), 1);
7998        assert_eq!(before[0].target, after[0].target);
7999        assert_ne!(before, after);
8000        assert!(pr_watch_grew(&before, &after));
8001        // A quiet tick still asks nothing.
8002        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8003        assert!(!pr_watch_grew(
8004            &before,
8005            &pr_watch_from_snapshot(&snap("aaa"))
8006        ));
8007    }
8008
8009    #[test]
8010    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8011        // An older daemon — or any branch tracking nothing — simply sends no
8012        // `upstream_sha`, which reads as `None` rather than failing the poll.
8013        let snap = json!({"repos":[{
8014            "main_repo":"omni-dev",
8015            "github":{"owner":"rust-works","name":"omni-dev"},
8016            "root":"/r",
8017            "polling_enabled":true,
8018            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8019        }]});
8020        let watch = pr_watch_from_snapshot(&snap);
8021        assert_eq!(watch.len(), 1);
8022        assert_eq!(watch[0].upstream_sha, None);
8023    }
8024
8025    #[test]
8026    fn start_pr_poller_is_a_noop_outside_a_runtime() {
8027        let svc = WorktreesService::new();
8028        svc.start_pr_poller();
8029        assert!(svc
8030            .poller
8031            .lock()
8032            .unwrap_or_else(PoisonError::into_inner)
8033            .is_none());
8034    }
8035
8036    #[tokio::test]
8037    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8038        let svc = WorktreesService::new();
8039        svc.start_pr_poller_with(
8040            Duration::from_millis(50),
8041            Duration::from_millis(10),
8042            PathBuf::from("/bin/true"),
8043        );
8044        let token = svc
8045            .poller
8046            .lock()
8047            .unwrap_or_else(PoisonError::into_inner)
8048            .as_ref()
8049            .map(|t| t.token.clone())
8050            .expect("poller started");
8051
8052        // Cancel the live task, then start again: if `start` spawned a replacement
8053        // it would orphan this one, so the token staying cancelled proves it did not.
8054        token.cancel();
8055        svc.start_pr_poller_with(
8056            Duration::from_millis(50),
8057            Duration::from_millis(10),
8058            PathBuf::from("/bin/true"),
8059        );
8060        assert!(svc
8061            .poller
8062            .lock()
8063            .unwrap_or_else(PoisonError::into_inner)
8064            .as_ref()
8065            .is_some_and(|t| t.token.is_cancelled()));
8066
8067        svc.shutdown().await;
8068        assert!(svc
8069            .poller
8070            .lock()
8071            .unwrap_or_else(PoisonError::into_inner)
8072            .is_none());
8073    }
8074
8075    // --- Rate-limit monitor (#1375) ---
8076
8077    /// A resource at `used`% of a 100-request budget.
8078    fn rl_resource(used: u64) -> RateLimitResource {
8079        RateLimitResource {
8080            used,
8081            limit: 100,
8082            remaining: 100 - used,
8083            percent: used as f64,
8084            reset: 0,
8085        }
8086    }
8087
8088    #[test]
8089    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8090        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8091            graphql: Some(rl_resource(graphql)),
8092            core: Some(rl_resource(core)),
8093            search: None,
8094        };
8095        // First poll already over threshold → warn.
8096        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8097        // First poll below → no warn.
8098        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8099        // Crossing upward → warn.
8100        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8101        // Staying over → no repeat warn.
8102        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8103        // Recovering below → no warn.
8104        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8105        // A *different* resource crossing while the first recovers is still caught.
8106        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8107    }
8108
8109    #[test]
8110    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8111        let svc = WorktreesService::new();
8112        svc.start_rate_limit_poller();
8113        assert!(svc
8114            .rate_limit_poller
8115            .lock()
8116            .unwrap_or_else(PoisonError::into_inner)
8117            .is_none());
8118    }
8119
8120    #[tokio::test]
8121    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8122        let svc = WorktreesService::new();
8123        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8124        let token = svc
8125            .rate_limit_poller
8126            .lock()
8127            .unwrap_or_else(PoisonError::into_inner)
8128            .as_ref()
8129            .map(|t| t.token.clone())
8130            .expect("poller started");
8131
8132        // Cancel the live task, then start again: a second start must not spawn a
8133        // replacement (which would orphan this one), so the token stays cancelled.
8134        token.cancel();
8135        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8136        assert!(svc
8137            .rate_limit_poller
8138            .lock()
8139            .unwrap_or_else(PoisonError::into_inner)
8140            .as_ref()
8141            .is_some_and(|t| t.token.is_cancelled()));
8142
8143        svc.shutdown().await;
8144        assert!(svc
8145            .rate_limit_poller
8146            .lock()
8147            .unwrap_or_else(PoisonError::into_inner)
8148            .is_none());
8149    }
8150
8151    #[tokio::test]
8152    // Holds the shim guard across awaits; see the note above.
8153    #[allow(clippy::await_holding_lock)]
8154    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8155        let bin_dir = tempfile::tempdir().unwrap();
8156        let (fake, _shim) = fake_gh(
8157            bin_dir.path(),
8158            r#"{"resources":{
8159                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8160                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8161            }}"#,
8162        );
8163        let svc = WorktreesService::new();
8164        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
8165        // is being watched — a lease makes it active without needing a window/folder.
8166        svc.registry.set_polling("rust-works", "omni-dev", true);
8167        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8168
8169        // Each poll spawns a real subprocess; wait on a generous deadline so a
8170        // loaded machine fails honestly rather than flaking.
8171        let snap = tokio::time::timeout(Duration::from_secs(30), async {
8172            loop {
8173                if let Some(snap) = svc.rate_limit_cache.get() {
8174                    return snap;
8175                }
8176                tokio::time::sleep(Duration::from_millis(25)).await;
8177            }
8178        })
8179        .await
8180        .expect("poller should populate the cache through the fake gh");
8181        assert_eq!(snap.graphql.unwrap().used, 4100);
8182        assert_eq!(snap.core.unwrap().used, 27);
8183
8184        // The reading reaches the built-in status field via the shared cache.
8185        assert!(svc.rate_limit_cache().get().is_some());
8186
8187        svc.shutdown().await;
8188        assert!(svc
8189            .rate_limit_poller
8190            .lock()
8191            .unwrap_or_else(PoisonError::into_inner)
8192            .is_none());
8193    }
8194
8195    #[tokio::test]
8196    // Holds the shim guard across awaits; see the note above.
8197    #[allow(clippy::await_holding_lock)]
8198    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8199        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
8200        // `/rate_limit` subprocess — the counting stub records zero spawns.
8201        let bin_dir = tempfile::tempdir().unwrap();
8202        let (fake, _shim, counter) = counting_fake_gh(
8203            bin_dir.path(),
8204            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8205        );
8206        let svc = WorktreesService::new();
8207        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8208
8209        // Give the loop several ticks; with nothing registered it must never poll.
8210        tokio::time::sleep(Duration::from_millis(300)).await;
8211        assert_eq!(
8212            gh_spawn_count(&counter),
8213            0,
8214            "idle daemon must not poll (#1389, fix 8b)"
8215        );
8216        assert!(svc.rate_limit_cache.get().is_none());
8217
8218        // Once a lease is active, the next tick populates the cache.
8219        svc.registry.set_polling("rust-works", "omni-dev", true);
8220        tokio::time::timeout(Duration::from_secs(30), async {
8221            loop {
8222                if svc.rate_limit_cache.get().is_some() {
8223                    return;
8224                }
8225                tokio::time::sleep(Duration::from_millis(25)).await;
8226            }
8227        })
8228        .await
8229        .expect("an active lease should resume polling");
8230        assert!(gh_spawn_count(&counter) >= 1);
8231        svc.shutdown().await;
8232    }
8233
8234    #[tokio::test]
8235    async fn rate_limit_poller_survives_a_failing_gh() {
8236        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
8237        // the degraded branch keeps the last (here, absent) reading rather than
8238        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
8239        let svc = WorktreesService::new();
8240        svc.registry.set_polling("rust-works", "omni-dev", true);
8241        svc.start_rate_limit_poller_with(
8242            Duration::from_millis(20),
8243            PathBuf::from("/no/such/gh/xyzzy"),
8244        );
8245        // Let it fail a few times: the cache stays empty and the task stays alive.
8246        tokio::time::sleep(Duration::from_millis(150)).await;
8247        assert!(svc.rate_limit_cache.get().is_none());
8248        assert!(
8249            svc.rate_limit_poller
8250                .lock()
8251                .unwrap_or_else(PoisonError::into_inner)
8252                .is_some(),
8253            "the loop must survive a failing gh, not panic out"
8254        );
8255        svc.shutdown().await;
8256    }
8257
8258    #[test]
8259    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8260        let svc = WorktreesService::new();
8261        // Empty cache → no rate-limit line (the pre-#1375 shape).
8262        let items = svc.menu().items;
8263        assert!(
8264            !items
8265                .iter()
8266                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8267            "no github line before the first poll"
8268        );
8269
8270        // Populate the cache → the first item is the rate-limit status line.
8271        svc.rate_limit_cache.replace(RateLimitSnapshot {
8272            graphql: Some(rl_resource(82)),
8273            core: Some(rl_resource(3)),
8274            search: None,
8275        });
8276        let items = svc.menu().items;
8277        assert!(
8278            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8279            "expected the github line first, got {items:?}"
8280        );
8281        assert!(
8282            matches!(items.get(1), Some(MenuItem::Separator)),
8283            "expected a separator after the github line"
8284        );
8285    }
8286
8287    #[tokio::test]
8288    // Holds the shim guard across awaits; see the note above.
8289    #[allow(clippy::await_holding_lock)]
8290    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8291        let dir = tempfile::tempdir().unwrap();
8292        github_repo(dir.path());
8293        let bin_dir = tempfile::tempdir().unwrap();
8294        // One repo, one branch → aliases r0/b0. A still-running check so the badge
8295        // stays pending and the loop keeps its fast cadence.
8296        let (fake, _shim) = fake_gh(
8297            bin_dir.path(),
8298            r#"{"data":{"r0":{"b0":{
8299                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8300                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8301                ]}}},
8302                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8303            }}}}"#,
8304        );
8305        let svc = WorktreesService::new();
8306        svc.handle(
8307            "register",
8308            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8309        )
8310        .await
8311        .unwrap();
8312        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8313        // act on it, as if the user had toggled it on.
8314        svc.registry.set_polling("rust-works", "omni-dev", true);
8315        svc.start_pr_poller_with(
8316            Duration::from_millis(50),
8317            Duration::from_millis(10),
8318            fake.clone(),
8319        );
8320
8321        // Wait on a generous wall-clock deadline: each poll spawns a real
8322        // subprocess, and under a loaded machine (a full `build.sh` runs a build
8323        // and clippy alongside) a tight budget flakes rather than fails honestly.
8324        let badge = tokio::time::timeout(Duration::from_secs(30), async {
8325            loop {
8326                if let Some(PrResolution::Pr(badge)) =
8327                    svc.pr_cache.get("rust-works", "omni-dev", "main")
8328                {
8329                    return badge;
8330                }
8331                tokio::time::sleep(Duration::from_millis(25)).await;
8332            }
8333        })
8334        .await
8335        .expect("poller should resolve a badge through the fake gh");
8336        assert_eq!(badge.number, 1337);
8337        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8338
8339        // The badge reaches the wire the windows actually read.
8340        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8341        assert_eq!(wt["pr"]["number"], json!(1337));
8342
8343        // And the loop is quiescent after shutdown: the generation must stop moving.
8344        svc.shutdown().await;
8345        let generation = svc.registry.change_generation();
8346        tokio::time::sleep(Duration::from_millis(120)).await;
8347        assert_eq!(
8348            svc.registry.change_generation(),
8349            generation,
8350            "no bumps after shutdown"
8351        );
8352    }
8353
8354    #[tokio::test]
8355    // Holds the shim guard across awaits; see the note above.
8356    #[allow(clippy::await_holding_lock)]
8357    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8358        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
8359        // the poller folds into the shared cache — so the graphql figure stays fresh
8360        // without a standalone `/rate_limit` call.
8361        let dir = tempfile::tempdir().unwrap();
8362        github_repo(dir.path());
8363        let bin_dir = tempfile::tempdir().unwrap();
8364        let (fake, _shim) = fake_gh(
8365            bin_dir.path(),
8366            r#"{"data":{
8367                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8368                             "resetAt":"2026-07-21T16:00:00Z"},
8369                "r0":{"b0":{
8370                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8371                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8372                  ]}}},
8373                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8374                }}
8375            }}"#,
8376        );
8377        let svc = WorktreesService::new();
8378        svc.handle(
8379            "register",
8380            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8381        )
8382        .await
8383        .unwrap();
8384        svc.registry.set_polling("rust-works", "omni-dev", true);
8385        // No rate-limit poller started: the only writer of the cache is the PR poll's
8386        // folded-in budget, so a populated graphql reading proves fix 8a.
8387        svc.start_pr_poller_with(
8388            Duration::from_millis(50),
8389            Duration::from_millis(10),
8390            fake.clone(),
8391        );
8392
8393        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8394            loop {
8395                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8396                    return g;
8397                }
8398                tokio::time::sleep(Duration::from_millis(25)).await;
8399            }
8400        })
8401        .await
8402        .expect("the PR poll should fold its budget into the cache");
8403        assert_eq!(graphql.used, 123);
8404        assert_eq!(graphql.limit, 5000);
8405        assert_eq!(graphql.remaining, 4877);
8406        svc.shutdown().await;
8407    }
8408
8409    #[tokio::test]
8410    // Holds the shim guard across awaits; see the note above.
8411    #[allow(clippy::await_holding_lock)]
8412    async fn pr_poll_counts_every_gh_call_exactly_once() {
8413        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
8414        // still counted. Compares the ground-truth number of `gh` subprocesses the
8415        // poll actually spawned against the number of successful `kind:"gh"` records
8416        // the counted `run_gh` choke point wrote — they must be equal, so a future
8417        // refactor cannot add an uncounted `gh` path (counted < spawns) without
8418        // failing here.
8419        let dir = tempfile::tempdir().unwrap();
8420        github_repo(dir.path());
8421        let bin_dir = tempfile::tempdir().unwrap();
8422        let (fake, _shim, counter) = counting_fake_gh(
8423            bin_dir.path(),
8424            r#"{"data":{"r0":{"b0":{
8425                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8426                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8427                ]}}},
8428                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8429            }}}}"#,
8430        );
8431        let log = bin_dir.path().join("log.jsonl");
8432        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8433
8434        let svc = WorktreesService::new();
8435        svc.handle(
8436            "register",
8437            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8438        )
8439        .await
8440        .unwrap();
8441        svc.registry.set_polling("rust-works", "omni-dev", true);
8442        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8443
8444        // Wait for at least one fetch to land.
8445        tokio::time::timeout(Duration::from_secs(30), async {
8446            loop {
8447                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8448                    return;
8449                }
8450                tokio::time::sleep(Duration::from_millis(25)).await;
8451            }
8452        })
8453        .await
8454        .expect("poller should fetch through the fake gh");
8455
8456        // Stop the loop so both counts are final (no in-flight gh), then compare.
8457        svc.shutdown().await;
8458        let spawns = gh_spawn_count(&counter);
8459        let counted = counted_gh_records(&log);
8460        std::env::remove_var("OMNI_DEV_LOG_FILE");
8461        assert!(
8462            spawns >= 1,
8463            "the poll should have spent at least one gh call"
8464        );
8465        assert_eq!(
8466            counted, spawns,
8467            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8468        );
8469    }
8470
8471    #[tokio::test]
8472    // Holds the shim guard across awaits; see the note above.
8473    #[allow(clippy::await_holding_lock)]
8474    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8475        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
8476        // its windows) that each *grow* the watch must collapse into ONE fetch on
8477        // the final set, not one per window. Two distinct repos appear back-to-back
8478        // inside the debounce window; a debounce-free loop would fetch twice.
8479        let dir_a = tempfile::tempdir().unwrap();
8480        let dir_b = tempfile::tempdir().unwrap();
8481        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
8482        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
8483        let bin_dir = tempfile::tempdir().unwrap();
8484        // Both terminal, so no fast pending cadence can add a second fetch.
8485        let (fake, _shim, counter) = counting_fake_gh(
8486            bin_dir.path(),
8487            r#"{"data":{
8488                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8489                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8490                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8491                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8492                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8493                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8494            }}"#,
8495        );
8496        let svc = WorktreesService::new();
8497        // Enable polling for both before they register, so the first snapshot after
8498        // the storm already counts them.
8499        svc.registry.set_polling("rust-works", "omni-dev", true);
8500        svc.registry.set_polling("rust-works", "other-repo", true);
8501        // `base` far larger than the test so only the storm — never the cadence —
8502        // can trigger a fetch; a generous debounce so the two registers land inside
8503        // one settle window even on a loaded machine.
8504        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8505        // The burst: both windows register back-to-back.
8506        svc.handle(
8507            "register",
8508            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8509        )
8510        .await
8511        .unwrap();
8512        // A beat between the two, so the first bump has (all but certainly)
8513        // woken the poller into its settle window before the second arrives —
8514        // exercising the debounce *restart*, not just a single coalesced wake.
8515        tokio::time::sleep(Duration::from_millis(50)).await;
8516        svc.handle(
8517            "register",
8518            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8519        )
8520        .await
8521        .unwrap();
8522
8523        // Wait until both badges resolve — proving the single fetch covered the full
8524        // final set, not just the first window.
8525        tokio::time::timeout(Duration::from_secs(30), async {
8526            loop {
8527                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8528                let b = svc
8529                    .pr_cache
8530                    .get("rust-works", "other-repo", "main")
8531                    .is_some();
8532                if a && b {
8533                    return;
8534                }
8535                tokio::time::sleep(Duration::from_millis(25)).await;
8536            }
8537        })
8538        .await
8539        .expect("the debounced fetch should resolve both repos");
8540
8541        svc.shutdown().await;
8542        assert_eq!(
8543            gh_spawn_count(&counter),
8544            1,
8545            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8546        );
8547    }
8548
8549    #[tokio::test]
8550    // Holds the shim guard across awaits; see the note above.
8551    #[allow(clippy::await_holding_lock)]
8552    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8553        // The settle loop is bounded: a drip of registry bumps, each landing
8554        // inside the debounce window, must not postpone the poll forever — the
8555        // overall deadline (4× the debounce) forces the snapshot mid-storm
8556        // (#1389, fix 2).
8557        let dir = tempfile::tempdir().unwrap();
8558        github_repo(dir.path());
8559        let bin_dir = tempfile::tempdir().unwrap();
8560        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8561        let svc = WorktreesService::new();
8562        svc.registry.set_polling("rust-works", "omni-dev", true);
8563        // `base` far past the timeout below, so only the grew-trigger — released by
8564        // the deadline — can fetch; a base the drip could outlive would let the
8565        // periodic cadence satisfy the assertion on a very slow run (#1426).
8566        svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8567        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8568        svc.handle("register", register.clone()).await.unwrap();
8569        // Re-register (an upsert, but still a bump) every 25ms — inside the 50ms
8570        // debounce — until the deadline-forced fetch actually lands. The observation
8571        // point is the condition itself, never a clock: sampling the counter at a
8572        // fixed instant would additionally assert that a `fork` + `exec` + `/bin/sh`
8573        // startup + file append all beat that instant, which is what made this flaky
8574        // under full-suite load (#1426). Under load the drip just runs longer. Every
8575        // iteration bumps, so the fetch is still observed *while bumps are arriving*
8576        // — a deadline-free settle loop never fetches here at all, and the timeout
8577        // *is* the assertion.
8578        let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8579            loop {
8580                tokio::time::sleep(Duration::from_millis(25)).await;
8581                svc.handle("register", register.clone()).await.unwrap();
8582                if gh_spawn_count(&counter) >= 1 {
8583                    return;
8584                }
8585            }
8586        })
8587        .await;
8588        svc.shutdown().await;
8589        forced_mid_drip.expect(
8590            "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8591        );
8592    }
8593
8594    #[tokio::test]
8595    // Holds the shim guard across awaits; see the note above.
8596    #[allow(clippy::await_holding_lock)]
8597    async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8598        // #1389, fix 4: a daemon restart within the backoff window serves badges
8599        // from the persisted cache and spends **no** gh call, because every current
8600        // target already has a fresh verdict.
8601        let dir = tempfile::tempdir().unwrap();
8602        let repo = github_repo(dir.path());
8603        let head = repo.head().unwrap().target().unwrap().to_string();
8604        let bin_dir = tempfile::tempdir().unwrap();
8605        // If the poller wrongly fetched, this empty reply would still spawn the stub
8606        // and bump the counter — which is exactly what the assertion catches.
8607        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8608
8609        // Persist a fresh cache the way the previous daemon would have: a badge for
8610        // `main` whose verdict is about the current head (so it is not stale),
8611        // watched at `(main, no upstream)`, resolved just now.
8612        let cache_path = bin_dir.path().join("pr-cache.json");
8613        let target = PrTarget {
8614            owner: "rust-works".into(),
8615            name: "omni-dev".into(),
8616            branch: "main".into(),
8617        };
8618        let prefs = pr_cache_prefs_from(
8619            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8620            &[watch("main", None)],
8621            Utc::now(),
8622        );
8623        write_pr_cache(&cache_path, &prefs).unwrap();
8624
8625        let svc = WorktreesService::new();
8626        svc.load_pr_cache(cache_path);
8627        svc.registry.set_polling("rust-works", "omni-dev", true);
8628        svc.handle(
8629            "register",
8630            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8631        )
8632        .await
8633        .unwrap();
8634        // `base` far larger than the test: the only fetch that could happen is the
8635        // immediate one we expect the warm cache to skip.
8636        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8637
8638        // The restored badge renders on the wire without a gh call.
8639        let number = tokio::time::timeout(Duration::from_secs(30), async {
8640            loop {
8641                let tree = svc.handle("tree", Value::Null).await.unwrap();
8642                if let Some(n) = repos_of(&tree)
8643                    .first()
8644                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8645                {
8646                    return n;
8647                }
8648                tokio::time::sleep(Duration::from_millis(25)).await;
8649            }
8650        })
8651        .await
8652        .expect("the restored badge should render from the warm cache");
8653        assert_eq!(number, 1337);
8654
8655        // Let the poller run a while, then confirm it stayed quiet.
8656        tokio::time::sleep(Duration::from_millis(300)).await;
8657        svc.shutdown().await;
8658        assert_eq!(
8659            gh_spawn_count(&counter),
8660            0,
8661            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8662        );
8663    }
8664
8665    #[tokio::test]
8666    // Holds the shim guard across awaits; see the note above.
8667    #[allow(clippy::await_holding_lock)]
8668    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8669        // #1389, fix 4, write side (the twin of the skip test above, which reads
8670        // a hand-written file): a successful resolve persists the cache —
8671        // creating the runtime dir if needed — so the *next* daemon restart
8672        // warm-starts from it.
8673        let dir = tempfile::tempdir().unwrap();
8674        github_repo(dir.path());
8675        let bin_dir = tempfile::tempdir().unwrap();
8676        let (fake, _shim) = fake_gh(
8677            bin_dir.path(),
8678            r#"{"data":{"r0":{"b0":{
8679                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8680                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8681                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8682            }}}}"#,
8683        );
8684        let svc = WorktreesService::new();
8685        // No file yet, and no parent dir either: the load takes the benign
8686        // NotFound arm, and the write must create the `0700` runtime dir.
8687        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8688        svc.load_pr_cache(cache_path.clone());
8689        svc.registry.set_polling("rust-works", "omni-dev", true);
8690        svc.handle(
8691            "register",
8692            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8693        )
8694        .await
8695        .unwrap();
8696        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8697
8698        // A partially-written or not-yet-written file simply retries: only a
8699        // fully parseable cache ends the wait.
8700        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8701            loop {
8702                if let Ok(bytes) = std::fs::read(&cache_path) {
8703                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8704                        if !prefs.entries.is_empty() {
8705                            return prefs;
8706                        }
8707                    }
8708                }
8709                tokio::time::sleep(Duration::from_millis(25)).await;
8710            }
8711        })
8712        .await
8713        .expect("a successful resolve should persist the cache file");
8714        svc.shutdown().await;
8715
8716        assert_eq!(prefs.entries[0].target.branch, "main");
8717        assert!(
8718            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8719            "{:?}",
8720            prefs.entries[0].resolution
8721        );
8722        assert_eq!(
8723            prefs.watched,
8724            vec![PersistedWatch {
8725                target: prefs.entries[0].target.clone(),
8726                upstream_sha: None
8727            }]
8728        );
8729        assert!(
8730            prefs.polled_at.is_some(),
8731            "the poll time is what ages the next warm start"
8732        );
8733    }
8734
8735    #[tokio::test]
8736    // Holds the shim guard across awaits; see the note above.
8737    #[allow(clippy::await_holding_lock)]
8738    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8739        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
8740        // to one counted `gh pr list` per repo. A generous TTL, so the second call
8741        // is served from the cache and spawns **no** second `gh` — the whole point.
8742        let bin_dir = tempfile::tempdir().unwrap();
8743        let (fake, _shim, counter) = counting_fake_gh(
8744            bin_dir.path(),
8745            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8746                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8747        );
8748        let svc = WorktreesService::new();
8749
8750        let prs = svc
8751            .open_prs_with("rust-works", "omni-dev", fake.clone())
8752            .await
8753            .expect("gh pr list should resolve");
8754        assert_eq!(prs.len(), 1);
8755        assert_eq!(prs[0]["number"], json!(42));
8756        assert_eq!(prs[0]["url"], json!("http://x/42"));
8757        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8758
8759        // A second window asking the same repo is served from the shared cache.
8760        let again = svc
8761            .open_prs_with("rust-works", "omni-dev", fake.clone())
8762            .await
8763            .expect("cache hit should resolve");
8764        assert_eq!(again, prs);
8765        assert_eq!(
8766            gh_spawn_count(&counter),
8767            1,
8768            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8769        );
8770
8771        // The op wrapper shapes the reply and validates the payload.
8772        let reply = svc
8773            .handle(
8774                "open-prs",
8775                json!({ "owner": "rust-works", "name": "omni-dev" }),
8776            )
8777            .await
8778            .expect("open-prs op should route");
8779        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8780        assert!(svc
8781            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
8782            .await
8783            .is_err());
8784    }
8785
8786    #[test]
8787    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8788        // The three degraded shapes a real `gh` presents — not installed, a
8789        // nonzero exit (auth/network), and output that is not the JSON array
8790        // the menu indexes into — must each be a distinct, actionable error
8791        // rather than a panic or a silently empty list (#1389, fix 7).
8792        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8793        assert!(
8794            err.to_string().contains("is the GitHub CLI installed"),
8795            "{err:#}"
8796        );
8797
8798        let bin_dir = tempfile::tempdir().unwrap();
8799        let _guard = shim_lock();
8800        let failing = bin_dir.path().join("fake-gh-fails");
8801        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8802        let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8803        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8804        assert!(err.to_string().contains("boom"), "{err:#}");
8805
8806        let object = bin_dir.path().join("fake-gh-object");
8807        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8808        let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8809        assert!(
8810            err.to_string().contains("did not return a JSON array"),
8811            "{err:#}"
8812        );
8813    }
8814
8815    #[tokio::test]
8816    // Holds the shim guard across awaits; see the note above.
8817    #[allow(clippy::await_holding_lock)]
8818    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8819        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
8820        // cadence and ignores even a grown watch, so no runaway can drain the shared
8821        // budget. A recent warm `last_poll` is seeded so the "first sight always
8822        // fetches" base case cannot mask the throttle — the only thing that could
8823        // fetch here is the grew-trigger, which the throttle suppresses.
8824        let dir = tempfile::tempdir().unwrap();
8825        github_repo(dir.path());
8826        let bin_dir = tempfile::tempdir().unwrap();
8827        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8828
8829        let svc = WorktreesService::new();
8830        // Warm start with an *empty* watch but a fresh poll time: the registered
8831        // repo then reads as a grown watch, while `last_poll` is recent enough that
8832        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
8833        *svc.pr_warm_start
8834            .lock()
8835            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8836            watched: vec![],
8837            polled_at: Utc::now(),
8838        });
8839        // Budget over the warn threshold before the poller starts.
8840        svc.rate_limit_cache.replace(RateLimitSnapshot {
8841            graphql: Some(rl_resource(90)),
8842            core: Some(rl_resource(3)),
8843            search: None,
8844        });
8845        svc.registry.set_polling("rust-works", "omni-dev", true);
8846        svc.handle(
8847            "register",
8848            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8849        )
8850        .await
8851        .unwrap();
8852        // `base` far larger than the test, so a fetch could only come from the
8853        // grew-trigger the throttle is meant to suppress.
8854        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8855
8856        // Let the poller wake on the registration and run a while.
8857        tokio::time::sleep(Duration::from_millis(300)).await;
8858        svc.shutdown().await;
8859        assert_eq!(
8860            gh_spawn_count(&counter),
8861            0,
8862            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8863        );
8864    }
8865
8866    #[tokio::test]
8867    // Holds the shim guard across awaits; see the note above.
8868    #[allow(clippy::await_holding_lock)]
8869    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8870        // The diff-and-drop contract: an unchanged poll must not bump, or every
8871        // window re-renders on every tick — the cost this design exists to avoid.
8872        let dir = tempfile::tempdir().unwrap();
8873        github_repo(dir.path());
8874        let bin_dir = tempfile::tempdir().unwrap();
8875        let (fake, _shim) = fake_gh(
8876            bin_dir.path(),
8877            r#"{"data":{"r0":{"b0":{
8878                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8879                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8880                ]}}},
8881                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8882            }}}}"#,
8883        );
8884        let svc = WorktreesService::new();
8885        svc.handle(
8886            "register",
8887            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8888        )
8889        .await
8890        .unwrap();
8891        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8892        // act on it, as if the user had toggled it on.
8893        svc.registry.set_polling("rust-works", "omni-dev", true);
8894        svc.start_pr_poller_with(
8895            Duration::from_millis(50),
8896            Duration::from_millis(10),
8897            fake.clone(),
8898        );
8899
8900        tokio::time::timeout(Duration::from_secs(30), async {
8901            loop {
8902                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8903                    return;
8904                }
8905                tokio::time::sleep(Duration::from_millis(25)).await;
8906            }
8907        })
8908        .await
8909        .expect("poller should resolve a badge through the fake gh");
8910        // The fake always answers identically, so after the first resolve every
8911        // subsequent poll is a no-change and must leave the generation alone.
8912        let settled = svc.registry.change_generation();
8913        tokio::time::sleep(Duration::from_millis(150)).await;
8914        assert_eq!(
8915            svc.registry.change_generation(),
8916            settled,
8917            "an unchanged poll must not bump the change-notify"
8918        );
8919        svc.shutdown().await;
8920    }
8921
8922    #[tokio::test]
8923    // Holds the shim guard across awaits; see the note above.
8924    #[allow(clippy::await_holding_lock)]
8925    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8926        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
8927        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
8928        // as `pr_none`, and — since the answer never changes — bumps the
8929        // change-notify only for the poll that first delivered it.
8930        let dir = tempfile::tempdir().unwrap();
8931        github_repo(dir.path());
8932        let bin_dir = tempfile::tempdir().unwrap();
8933        let (fake, _shim) = fake_gh(
8934            bin_dir.path(),
8935            r#"{"data":{"r0":{"b0":{
8936                "target":{"oid":"abc","statusCheckRollup":null},
8937                "associatedPullRequests":{"nodes":[]}
8938            }}}}"#,
8939        );
8940        let svc = WorktreesService::new();
8941        svc.handle(
8942            "register",
8943            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8944        )
8945        .await
8946        .unwrap();
8947        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8948        // act on it, as if the user had toggled it on.
8949        svc.registry.set_polling("rust-works", "omni-dev", true);
8950        svc.start_pr_poller_with(
8951            Duration::from_millis(50),
8952            Duration::from_millis(10),
8953            fake.clone(),
8954        );
8955
8956        tokio::time::timeout(Duration::from_secs(30), async {
8957            loop {
8958                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8959                    return;
8960                }
8961                tokio::time::sleep(Duration::from_millis(25)).await;
8962            }
8963        })
8964        .await
8965        .expect("poller should resolve the negative through the fake gh");
8966
8967        // The negative reaches the wire the windows actually read.
8968        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8969        assert_eq!(wt["pr_none"], json!(true));
8970        assert!(wt.get("pr").is_none(), "{wt:?}");
8971
8972        // Identical re-polls of the same negative must not bump.
8973        let settled = svc.registry.change_generation();
8974        tokio::time::sleep(Duration::from_millis(150)).await;
8975        assert_eq!(
8976            svc.registry.change_generation(),
8977            settled,
8978            "an unchanged negative must not bump the change-notify"
8979        );
8980        svc.shutdown().await;
8981    }
8982
8983    #[test]
8984    fn sync_indicator_formats_only_with_upstream() {
8985        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8986        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8987        assert_eq!(sync_indicator(None, None), None);
8988        // A partial pair (no real upstream) yields nothing.
8989        assert_eq!(sync_indicator(Some(1), None), None);
8990    }
8991
8992    #[tokio::test]
8993    async fn list_enriches_entries_with_git_status() {
8994        let dir = tempfile::tempdir().unwrap();
8995        let repo = init_repo(dir.path());
8996        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8997        repo.set_head("refs/heads/main").unwrap();
8998
8999        let svc = WorktreesService::new();
9000        svc.handle(
9001            "register",
9002            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9003        )
9004        .await
9005        .unwrap();
9006        let payload = svc.handle("list", Value::Null).await.unwrap();
9007        let windows = windows_of(&payload);
9008        assert_eq!(windows.len(), 1);
9009        assert_eq!(
9010            windows[0].get("branch").and_then(Value::as_str),
9011            Some("main")
9012        );
9013        // No upstream configured → the ahead/behind keys are absent, not zero.
9014        assert!(windows[0].get("ahead").is_none());
9015        assert!(windows[0].get("behind").is_none());
9016        // The main repo name is enriched onto the entry.
9017        assert_eq!(
9018            windows[0].get("main_repo").and_then(Value::as_str),
9019            dir.path().file_name().and_then(|n| n.to_str())
9020        );
9021
9022        // A non-repo folder is still listed, just without a branch or main repo.
9023        let plain = tempfile::tempdir().unwrap();
9024        svc.handle(
9025            "register",
9026            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9027        )
9028        .await
9029        .unwrap();
9030        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9031        let w2 = windows
9032            .iter()
9033            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9034            .unwrap();
9035        assert!(w2.get("branch").is_none());
9036        assert!(w2.get("main_repo").is_none());
9037    }
9038
9039    #[test]
9040    fn window_label_prefers_git_branch_over_title() {
9041        let dir = tempfile::tempdir().unwrap();
9042        let repo = init_repo(dir.path());
9043        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9044        repo.set_head("refs/heads/main").unwrap();
9045        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9046        let entry = WindowEntry {
9047            key: "k".to_string(),
9048            folders: vec![dir.path().to_path_buf()],
9049            // Both the companion `repo` and `title` are overridden by the
9050            // git-derived main repo name and computed branch.
9051            repo: Some("companion-repo".to_string()),
9052            title: Some("ignored title".to_string()),
9053            pid: None,
9054            last_seen: Utc::now(),
9055        };
9056        // Main checkout: `repo · branch`, and with no upstream there is no sync.
9057        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9058    }
9059
9060    #[tokio::test]
9061    async fn list_includes_ahead_behind_for_tracking_branch() {
9062        let dir = tempfile::tempdir().unwrap();
9063        let _repo = diverging_repo(dir.path());
9064
9065        let svc = WorktreesService::new();
9066        svc.handle(
9067            "register",
9068            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9069        )
9070        .await
9071        .unwrap();
9072        let payload = svc.handle("list", Value::Null).await.unwrap();
9073        let windows = windows_of(&payload);
9074        // A tracking branch serializes branch plus both divergence counts.
9075        assert_eq!(
9076            windows[0].get("branch").and_then(Value::as_str),
9077            Some("main")
9078        );
9079        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9080        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9081    }
9082
9083    #[test]
9084    fn window_label_includes_sync_for_tracking_branch() {
9085        let dir = tempfile::tempdir().unwrap();
9086        let _repo = diverging_repo(dir.path());
9087        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9088        let entry = WindowEntry {
9089            key: "k".to_string(),
9090            folders: vec![dir.path().to_path_buf()],
9091            repo: Some("companion-repo".to_string()),
9092            title: None,
9093            pid: None,
9094            last_seen: Utc::now(),
9095        };
9096        // A tracking branch appends the `(+ahead -behind)` sync indicator.
9097        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9098    }
9099
9100    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
9101    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
9102    /// <wt_path>`.
9103    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9104        let commit = repo.find_commit(base).unwrap();
9105        repo.branch(branch, &commit, false).unwrap();
9106        let reference = repo
9107            .find_reference(&format!("refs/heads/{branch}"))
9108            .unwrap();
9109        let mut opts = git2::WorktreeAddOptions::new();
9110        opts.reference(Some(&reference));
9111        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9112    }
9113
9114    #[test]
9115    fn git_status_marks_linked_worktree_and_names_parent_repo() {
9116        let main_dir = tempfile::tempdir().unwrap();
9117        let repo = init_repo(main_dir.path());
9118        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9119        repo.set_head("refs/heads/main").unwrap();
9120
9121        // A linked worktree checked out on a new `feature` branch, in a
9122        // directory whose basename is deliberately *not* the repo name.
9123        let wt_parent = tempfile::tempdir().unwrap();
9124        let wt_path = wt_parent.path().join("feature-wt");
9125        add_worktree(&repo, a, &wt_path, "feature");
9126
9127        let status = git_status(&wt_path);
9128        assert!(status.is_worktree);
9129        assert_eq!(status.branch.as_deref(), Some("feature"));
9130        // The worktree names its *parent* repo, not its worktree-folder basename.
9131        assert_eq!(
9132            status.main_repo.as_deref(),
9133            main_dir.path().file_name().and_then(|n| n.to_str())
9134        );
9135
9136        // The main checkout resolves the same repo name and is not a worktree.
9137        let main_status = git_status(main_dir.path());
9138        assert!(!main_status.is_worktree);
9139        assert_eq!(main_status.main_repo, status.main_repo);
9140    }
9141
9142    #[test]
9143    fn window_label_marks_worktree_with_fork_glyph() {
9144        let main_dir = tempfile::tempdir().unwrap();
9145        let repo = init_repo(main_dir.path());
9146        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9147        repo.set_head("refs/heads/main").unwrap();
9148        let wt_parent = tempfile::tempdir().unwrap();
9149        let wt_path = wt_parent.path().join("feature-wt");
9150        add_worktree(&repo, a, &wt_path, "feature");
9151
9152        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9153        let entry = WindowEntry {
9154            key: "k".to_string(),
9155            folders: vec![wt_path],
9156            repo: Some("feature-wt".to_string()),
9157            title: None,
9158            pid: None,
9159            last_seen: Utc::now(),
9160        };
9161        // A worktree line: parent repo, the fork glyph, then the branch (no
9162        // upstream here, so no sync suffix).
9163        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9164    }
9165
9166    #[test]
9167    fn main_repo_name_derives_from_common_dir() {
9168        // Normal layout: the repo is the directory that contains `.git`.
9169        assert_eq!(
9170            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9171            Some("omni-dev")
9172        );
9173        // A trailing slash on the common dir does not change the answer.
9174        assert_eq!(
9175            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9176            Some("omni-dev")
9177        );
9178        // A bare repo: its own directory name, without the `.git` suffix.
9179        assert_eq!(
9180            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9181            Some("omni-dev")
9182        );
9183        // A `.git` at the filesystem root has no parent name to use.
9184        assert_eq!(main_repo_name(Path::new("/.git")), None);
9185    }
9186
9187    // --- Repo/worktree tree (#1265) ----------------------------------------
9188
9189    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
9190    /// temporary payload).
9191    fn repos_of(payload: &Value) -> Vec<Value> {
9192        payload
9193            .get("repos")
9194            .and_then(Value::as_array)
9195            .expect("repos array")
9196            .clone()
9197    }
9198
9199    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9200        Some(GithubIdentity {
9201            owner: owner.to_string(),
9202            name: name.to_string(),
9203        })
9204    }
9205
9206    #[test]
9207    fn github_identity_parses_supported_forms() {
9208        // https / http, with and without the `.git` suffix.
9209        assert_eq!(
9210            github_identity("https://github.com/rust-works/omni-dev.git"),
9211            github("rust-works", "omni-dev")
9212        );
9213        assert_eq!(
9214            github_identity("https://github.com/rust-works/omni-dev"),
9215            github("rust-works", "omni-dev")
9216        );
9217        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9218        // SCP-like and ssh:// / git:// forms.
9219        assert_eq!(
9220            github_identity("git@github.com:rust-works/omni-dev.git"),
9221            github("rust-works", "omni-dev")
9222        );
9223        assert_eq!(
9224            github_identity("ssh://git@github.com/o/r.git"),
9225            github("o", "r")
9226        );
9227        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9228        // A trailing slash and surrounding whitespace are tolerated.
9229        assert_eq!(
9230            github_identity("  https://github.com/o/r/  "),
9231            github("o", "r")
9232        );
9233    }
9234
9235    #[test]
9236    fn github_identity_rejects_non_github_and_malformed() {
9237        // Non-GitHub hosts.
9238        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9239        assert_eq!(github_identity("git@example.com:o/r.git"), None);
9240        // Missing or extra path segments.
9241        assert_eq!(github_identity("https://github.com/onlyowner"), None);
9242        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9243        assert_eq!(github_identity("https://github.com/"), None);
9244        // Not a URL at all.
9245        assert_eq!(github_identity("not a url"), None);
9246    }
9247
9248    #[test]
9249    fn remote_github_identity_reads_origin_then_falls_back() {
9250        let dir = tempfile::tempdir().unwrap();
9251        let repo = init_repo(dir.path());
9252        // No remotes → None.
9253        assert_eq!(remote_github_identity(&repo), None);
9254        // A non-GitHub origin is not a match.
9255        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9256        assert_eq!(remote_github_identity(&repo), None);
9257        // A GitHub origin resolves to its identity.
9258        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9259            .unwrap();
9260        assert_eq!(
9261            remote_github_identity(&repo),
9262            github("rust-works", "omni-dev")
9263        );
9264
9265        // Origin non-GitHub but another remote is GitHub: the fallback loop over
9266        // the remaining remotes finds it.
9267        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9268            .unwrap();
9269        repo.remote("upstream", "https://github.com/other/proj.git")
9270            .unwrap();
9271        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9272    }
9273
9274    #[tokio::test]
9275    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9276        let svc = WorktreesService::new();
9277        // No windows → an empty repo set (not an error), toggle at its default.
9278        assert_eq!(
9279            svc.handle("tree", Value::Null).await.unwrap(),
9280            json!({ "repos": [], "show_closed": true })
9281        );
9282        // A plain non-repo folder is skipped rather than sinking the op.
9283        let plain = tempfile::tempdir().unwrap();
9284        svc.handle(
9285            "register",
9286            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9287        )
9288        .await
9289        .unwrap();
9290        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9291    }
9292
9293    #[tokio::test]
9294    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9295        let main_dir = tempfile::tempdir().unwrap();
9296        let repo = init_repo(main_dir.path());
9297        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9298        repo.set_head("refs/heads/main").unwrap();
9299        // A GitHub origin so the repo carries an identity in the payload.
9300        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9301            .unwrap();
9302
9303        // A linked worktree on a new `feature` branch, in a directory whose
9304        // basename is deliberately not the repo name.
9305        let wt_parent = tempfile::tempdir().unwrap();
9306        let wt_path = wt_parent.path().join("feature-wt");
9307        add_worktree(&repo, a, &wt_path, "feature");
9308
9309        let svc = WorktreesService::new();
9310        // A window open on the main checkout and one on the linked worktree —
9311        // two windows, but one repo (they must dedupe).
9312        svc.handle(
9313            "register",
9314            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9315        )
9316        .await
9317        .unwrap();
9318        svc.handle(
9319            "register",
9320            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9321        )
9322        .await
9323        .unwrap();
9324
9325        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9326        assert_eq!(
9327            repos.len(),
9328            1,
9329            "two worktrees of one repo dedupe: {repos:?}"
9330        );
9331        let repo0 = &repos[0];
9332        // Repo identity is the parent-repo name (not a worktree-folder basename).
9333        assert_eq!(
9334            repo0.get("main_repo").and_then(Value::as_str),
9335            main_dir.path().file_name().and_then(|n| n.to_str())
9336        );
9337        assert_eq!(
9338            repo0.pointer("/github/owner").and_then(Value::as_str),
9339            Some("rust-works")
9340        );
9341        assert_eq!(
9342            repo0.pointer("/github/name").and_then(Value::as_str),
9343            Some("omni-dev")
9344        );
9345        assert!(repo0.get("root").and_then(Value::as_str).is_some());
9346
9347        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9348        assert_eq!(worktrees.len(), 2);
9349        // Main working tree first: is_main, open, with the main window's key.
9350        let main_wt = &worktrees[0];
9351        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9352        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9353        assert_eq!(
9354            main_wt.get("window_key").and_then(Value::as_str),
9355            Some("wm")
9356        );
9357        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9358        // Linked worktree: not main, open via the feature window.
9359        let linked = &worktrees[1];
9360        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9361        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9362        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9363        assert_eq!(
9364            linked.get("branch").and_then(Value::as_str),
9365            Some("feature")
9366        );
9367    }
9368
9369    #[tokio::test]
9370    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9371        let main_dir = tempfile::tempdir().unwrap();
9372        let repo = init_repo(main_dir.path());
9373        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9374        repo.set_head("refs/heads/main").unwrap();
9375        // No remote at all → the repo carries no `github` identity.
9376        let wt_parent = tempfile::tempdir().unwrap();
9377        let wt_path = wt_parent.path().join("feature-wt");
9378        add_worktree(&repo, a, &wt_path, "feature");
9379
9380        let svc = WorktreesService::new();
9381        // Only the main checkout has a window; the linked worktree has none.
9382        svc.handle(
9383            "register",
9384            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9385        )
9386        .await
9387        .unwrap();
9388
9389        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9390        assert_eq!(repos.len(), 1);
9391        assert!(repos[0].get("github").is_none(), "no remote → no github");
9392        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9393        let linked = worktrees
9394            .iter()
9395            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9396            .expect("the linked worktree");
9397        // Enumerated even though no window has it open, and marked closed.
9398        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9399        assert!(linked.get("window_key").is_none());
9400    }
9401
9402    // --- Close op (#1277) --------------------------------------------------
9403
9404    /// Builds a repo whose main working tree is on `trunk` with one **clean**
9405    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
9406    /// paths stay valid) and the linked worktree path.
9407    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9408        let main_dir = tempfile::tempdir().unwrap();
9409        let repo = init_repo(main_dir.path());
9410        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9411        repo.set_head("refs/heads/trunk").unwrap();
9412        let wt_parent = tempfile::tempdir().unwrap();
9413        let wt_path = wt_parent.path().join("feature-wt");
9414        add_worktree(&repo, a, &wt_path, "feature");
9415        (main_dir, wt_parent, wt_path)
9416    }
9417
9418    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
9419    /// repo — the shape a multi-select delete fans out over, and the only one where
9420    /// two prunes share a `.git/worktrees` to race on (#1359).
9421    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9422    {
9423        let main_dir = tempfile::tempdir().unwrap();
9424        let repo = init_repo(main_dir.path());
9425        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9426        repo.set_head("refs/heads/trunk").unwrap();
9427        let wt_parent = tempfile::tempdir().unwrap();
9428        let first = wt_parent.path().join("first-wt");
9429        let second = wt_parent.path().join("second-wt");
9430        add_worktree(&repo, a, &first, "first");
9431        add_worktree(&repo, a, &second, "second");
9432        (main_dir, wt_parent, first, second)
9433    }
9434
9435    #[tokio::test]
9436    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9437        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9438        let svc = Arc::new(WorktreesService::new());
9439
9440        // The multi-select fan-out: one `close` op per target, both in flight at
9441        // once against the one repo's shared admin state. Genuinely concurrent
9442        // even on this single-threaded runtime — each op's prune is a
9443        // `spawn_blocking`, so awaiting its join yields to the other op.
9444        //
9445        // This guards the fan-out end-to-end (both ops complete, neither is
9446        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
9447        // a race detector for the lock, because it is not one — it passes with the
9448        // guard removed, the two prunes being far too quick to collide reliably.
9449        let close = |path: PathBuf| {
9450            let svc = svc.clone();
9451            async move {
9452                svc.handle(
9453                    "close",
9454                    json!({ "path": path, "remove": true, "confirmed": true }),
9455                )
9456                .await
9457            }
9458        };
9459        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9460
9461        assert_eq!(a.unwrap(), json!({ "removed": true }));
9462        assert_eq!(b.unwrap(), json!({ "removed": true }));
9463        assert!(!first.exists());
9464        assert!(!second.exists());
9465        // Both *admin* entries pruned too, not merely the directories — the half
9466        // the two ops contend on.
9467        let repo = Repository::open(main_dir.path()).unwrap();
9468        assert!(repo.worktrees().unwrap().is_empty());
9469    }
9470
9471    // --- Merge-queue op (#1401) --------------------------------------------
9472
9473    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
9474    /// upstream points at the **same** commit (nothing to push) and a github
9475    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
9476    /// empty (clean) working directory.
9477    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9478        let repo = init_repo(dir);
9479        let refname = format!("refs/heads/{branch}");
9480        let head = empty_commit(&repo, Some(&refname), &[], "A");
9481        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9482            .unwrap();
9483        repo.set_head(&refname).unwrap();
9484        let mut cfg = repo.config().unwrap();
9485        cfg.set_str("remote.origin.url", url).unwrap();
9486        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9487            .unwrap();
9488        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9489            .unwrap();
9490        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9491            .unwrap();
9492        repo
9493    }
9494
9495    #[test]
9496    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9497        let dir = tempfile::tempdir().unwrap();
9498        let _repo = pushed_github_repo(
9499            dir.path(),
9500            "https://github.com/rust-works/omni-dev.git",
9501            "feature",
9502        );
9503        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9504        assert_eq!(
9505            ok.target,
9506            PrTarget {
9507                owner: "rust-works".into(),
9508                name: "omni-dev".into(),
9509                branch: "feature".into(),
9510            }
9511        );
9512        assert!(!ok.head_sha.is_empty());
9513    }
9514
9515    #[test]
9516    fn evaluate_local_skips_an_unborn_head() {
9517        let dir = tempfile::tempdir().unwrap();
9518        let _repo = init_repo(dir.path()); // no commits
9519        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9520    }
9521
9522    #[test]
9523    fn evaluate_local_skips_a_branch_with_no_upstream() {
9524        let dir = tempfile::tempdir().unwrap();
9525        let repo = init_repo(dir.path());
9526        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9527        repo.set_head("refs/heads/main").unwrap();
9528        // A github URL but no tracking config → nothing was ever pushed.
9529        repo.config()
9530            .unwrap()
9531            .set_str("remote.origin.url", "https://github.com/o/r.git")
9532            .unwrap();
9533        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9534    }
9535
9536    #[test]
9537    fn evaluate_local_skips_unpushed_local_commits() {
9538        let dir = tempfile::tempdir().unwrap();
9539        let repo = init_repo(dir.path());
9540        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9541        let a_commit = repo.find_commit(a).unwrap();
9542        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
9543        repo.reference("refs/remotes/origin/main", a, true, "o")
9544            .unwrap();
9545        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9546        drop(a_commit);
9547        repo.set_head("refs/heads/main").unwrap();
9548        let mut cfg = repo.config().unwrap();
9549        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9550            .unwrap();
9551        // The fetch refspec is what lets git2 map the branch to its tracking ref;
9552        // without it `upstream()` fails and the branch reads as having none.
9553        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9554            .unwrap();
9555        cfg.set_str("branch.main.remote", "origin").unwrap();
9556        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9557        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9558    }
9559
9560    #[test]
9561    fn evaluate_local_skips_a_detached_head() {
9562        let dir = tempfile::tempdir().unwrap();
9563        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9564        let head = repo.head().unwrap().target().unwrap();
9565        repo.set_head_detached(head).unwrap();
9566        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9567    }
9568
9569    #[test]
9570    fn evaluate_local_skips_a_non_github_remote() {
9571        let dir = tempfile::tempdir().unwrap();
9572        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9573        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9574    }
9575
9576    #[test]
9577    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9578        // A path git cannot discover a repo from is refused up front (defensive:
9579        // the UI only ever sends real worktrees). A nonexistent path is used so the
9580        // result never depends on whether the temp dir sits inside a checkout.
9581        assert_eq!(
9582            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9583                .unwrap_err()
9584                .kind,
9585            "not-a-repo"
9586        );
9587    }
9588
9589    #[test]
9590    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9591        // The audit line's `tracing` field expressions only evaluate when an INFO
9592        // subscriber is active — the sync helper makes that testable.
9593        let req = MergeQueueRequest {
9594            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9595            requester_key: Some("win-9".into()),
9596            check: true,
9597            confirmed: false,
9598        };
9599        let logs = capture_info(|| log_merge_check(&req, 1, 1));
9600        assert!(logs.contains("merge-queue check"), "{logs}");
9601        assert!(logs.contains("win-9"), "{logs}");
9602        assert!(logs.contains("requested=2"), "{logs}");
9603        assert!(logs.contains("eligible=1"), "{logs}");
9604    }
9605
9606    #[test]
9607    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9608        // A CLI-style requester (no window key) logs the dash fallback.
9609        let req = MergeQueueRequest {
9610            paths: vec![PathBuf::from("/a")],
9611            requester_key: None,
9612            check: false,
9613            confirmed: true,
9614        };
9615        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9616        assert!(logs.contains("merge-queue enqueue"), "{logs}");
9617        assert!(logs.contains("queued=2"), "{logs}");
9618        assert!(logs.contains("failed=1"), "{logs}");
9619    }
9620
9621    #[test]
9622    fn evaluate_local_flags_dirty_then_untracked() {
9623        // A linked worktree with a real checked-out file, so status is meaningful.
9624        let main_dir = tempfile::tempdir().unwrap();
9625        let repo = init_repo(main_dir.path());
9626        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9627        repo.set_head("refs/heads/main").unwrap();
9628        let wt_parent = tempfile::tempdir().unwrap();
9629        let wt_path = wt_parent.path().join("feature-wt");
9630        add_worktree(&repo, a, &wt_path, "feature");
9631
9632        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
9633        let clean = evaluate_local(&wt_path).unwrap_err();
9634        assert_ne!(clean.kind, "dirty");
9635        assert_ne!(clean.kind, "untracked");
9636
9637        // Modify the tracked file → dirty (gate 1, before any network call).
9638        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9639        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9640
9641        // Restore it, add a new file → untracked.
9642        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9643        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9644        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9645    }
9646
9647    #[test]
9648    fn is_conflicting_blocks_only_dirty_and_conflicting() {
9649        assert!(is_conflicting(Some("CONFLICTING")));
9650        assert!(is_conflicting(Some("DIRTY")));
9651        assert!(!is_conflicting(Some("CLEAN")));
9652        assert!(!is_conflicting(Some("BLOCKED")));
9653        assert!(!is_conflicting(Some("UNKNOWN")));
9654        assert!(!is_conflicting(None));
9655    }
9656
9657    #[test]
9658    fn merge_queue_request_parses_batch_and_phase_flags() {
9659        let req: MergeQueueRequest = serde_json::from_value(json!({
9660            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9661        }))
9662        .unwrap();
9663        assert_eq!(req.paths.len(), 2);
9664        assert_eq!(req.requester_key.as_deref(), Some("w1"));
9665        assert!(req.confirmed);
9666        assert!(!req.check);
9667        // Minimal payload: just paths; every other field defaults.
9668        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9669        assert!(req.paths.is_empty());
9670        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9671    }
9672
9673    #[test]
9674    fn queued_pr_omits_already_queued_when_false() {
9675        let v = serde_json::to_value(QueuedPr {
9676            path: "/a".into(),
9677            number: 5,
9678            already_queued: false,
9679        })
9680        .unwrap();
9681        assert!(v.get("already_queued").is_none(), "{v}");
9682        let v = serde_json::to_value(QueuedPr {
9683            path: "/a".into(),
9684            number: 5,
9685            already_queued: true,
9686        })
9687        .unwrap();
9688        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9689    }
9690
9691    #[tokio::test]
9692    async fn merge_queue_check_on_empty_selection_reports_nothing() {
9693        let svc = WorktreesService::new();
9694        let reply = svc
9695            .handle("merge-queue", json!({ "paths": [], "check": true }))
9696            .await
9697            .unwrap();
9698        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9699    }
9700
9701    #[tokio::test]
9702    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9703        // An unborn repo is skipped by the *local* gates, so the op never shells
9704        // `gh` — the check completes with no network stub.
9705        let dir = tempfile::tempdir().unwrap();
9706        let _repo = init_repo(dir.path());
9707        let svc = WorktreesService::new();
9708        let reply = svc
9709            .handle(
9710                "merge-queue",
9711                json!({ "paths": [dir.path()], "check": true }),
9712            )
9713            .await
9714            .unwrap();
9715        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9716        assert_eq!(skipped.len(), 1);
9717        assert_eq!(
9718            skipped[0].get("kind").and_then(Value::as_str),
9719            Some("no-commits")
9720        );
9721        assert!(reply
9722            .get("eligible")
9723            .and_then(Value::as_array)
9724            .unwrap()
9725            .is_empty());
9726    }
9727
9728    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
9729    /// that clears the local gates, so a test can drive the *network* gates by
9730    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
9731    fn ready_worktree() -> (tempfile::TempDir, String) {
9732        let dir = tempfile::tempdir().unwrap();
9733        let repo = pushed_github_repo(
9734            dir.path(),
9735            "https://github.com/rust-works/omni-dev.git",
9736            "feature",
9737        );
9738        let head = repo.head().unwrap().target().unwrap().to_string();
9739        (dir, head)
9740    }
9741
9742    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
9743    /// one check of `conclusion`, and one PR node `pr`.
9744    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9745        format!(
9746            r#"{{"data":{{"r0":{{"b0":{{
9747                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9748                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9749                ]}}}}}},
9750                "associatedPullRequests":{{"nodes":[{pr}]}}
9751            }}}}}}}}"#
9752        )
9753    }
9754
9755    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
9756    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
9757    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
9758    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9759        let ghdir = tempfile::tempdir().unwrap();
9760        let (bin, _shim) = fake_gh(ghdir.path(), reply);
9761        let paths = vec![head_dir.to_path_buf()];
9762        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9763        if let Some(e) = eligible.first() {
9764            return Ok(e.number);
9765        }
9766        Err(skipped.remove(0).kind)
9767    }
9768
9769    #[test]
9770    fn evaluate_batch_marks_a_ready_pr_eligible() {
9771        let (dir, head) = ready_worktree();
9772        let pr = format!(
9773            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9774        );
9775        assert_eq!(
9776            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9777            Ok(9)
9778        );
9779    }
9780
9781    #[test]
9782    fn evaluate_batch_skips_a_draft_pr() {
9783        let (dir, head) = ready_worktree();
9784        let pr = format!(
9785            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9786        );
9787        assert_eq!(
9788            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9789            Err("draft".to_string())
9790        );
9791    }
9792
9793    #[test]
9794    fn evaluate_batch_skips_a_conflicting_pr() {
9795        let (dir, head) = ready_worktree();
9796        let pr = format!(
9797            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9798        );
9799        assert_eq!(
9800            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9801            Err("conflicting".to_string())
9802        );
9803    }
9804
9805    #[test]
9806    fn evaluate_batch_skips_a_pr_with_failing_checks() {
9807        let (dir, head) = ready_worktree();
9808        let pr = format!(
9809            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9810        );
9811        assert_eq!(
9812            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9813            Err("checks-failing".to_string())
9814        );
9815    }
9816
9817    #[test]
9818    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9819        let (dir, head) = ready_worktree();
9820        // The remote PR head is a different commit than the local head.
9821        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9822        assert_eq!(
9823            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9824            Err("stale".to_string())
9825        );
9826    }
9827
9828    #[test]
9829    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9830        let (dir, head) = ready_worktree();
9831        // The ref resolves but no open PR heads it.
9832        let reply = format!(
9833            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9834        );
9835        assert_eq!(
9836            network_gate_outcome(dir.path(), &reply),
9837            Err("no-pr".to_string())
9838        );
9839    }
9840
9841    #[test]
9842    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9843        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
9844        // already-queued PR needs no mutation and is reported queued.
9845        let eligible = vec![
9846            Eligible {
9847                path: PathBuf::from("/wt/a"),
9848                number: 1,
9849                url: "u".into(),
9850                branch: "a".into(),
9851                pr_id: "PR_A".into(),
9852                already_queued: true,
9853            },
9854            Eligible {
9855                path: PathBuf::from("/wt/b"),
9856                number: 2,
9857                url: "u".into(),
9858                branch: "b".into(),
9859                pr_id: "PR_B".into(),
9860                already_queued: false,
9861            },
9862        ];
9863        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9864        assert_eq!(queued.len(), 1);
9865        assert_eq!(queued[0].number, 1);
9866        assert!(queued[0].already_queued);
9867        assert_eq!(failed.len(), 1);
9868        assert_eq!(failed[0].number, 2);
9869    }
9870
9871    #[test]
9872    fn enqueue_eligible_records_a_github_rejection_as_failed() {
9873        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
9874        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
9875        // rather than sinking the batch.
9876        let ghdir = tempfile::tempdir().unwrap();
9877        let (bin, _shim) = fake_gh(
9878            ghdir.path(),
9879            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9880        );
9881        let eligible = vec![Eligible {
9882            path: PathBuf::from("/wt/a"),
9883            number: 7,
9884            url: "u".into(),
9885            branch: "a".into(),
9886            pr_id: "PR_A".into(),
9887            already_queued: false,
9888        }];
9889        let (queued, failed) = enqueue_eligible(&bin, eligible);
9890        assert!(queued.is_empty(), "{queued:?}");
9891        assert_eq!(failed.len(), 1);
9892        assert_eq!(failed[0].number, 7);
9893        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
9894        // instead surface as an `Err`, still landing in `failed[]`).
9895        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9896    }
9897
9898    #[tokio::test]
9899    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9900    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9901        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
9902        // exercising the `PrRef` mapping the empty-selection tests cannot.
9903        let (dir, head) = ready_worktree();
9904        let pr = format!(
9905            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9906        );
9907        let ghdir = tempfile::tempdir().unwrap();
9908        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9909        let svc = WorktreesService::new();
9910        let req = MergeQueueRequest {
9911            paths: vec![dir.path().to_path_buf()],
9912            requester_key: None,
9913            check: true,
9914            confirmed: false,
9915        };
9916        // `merge_queue_with` shells the freshly-written `fake-gh` shim via
9917        // `spawn_blocking` — a test-only ETXTBSY exec race the `shim_lock`
9918        // guard alone does not retry (see test_support::shim's module docs).
9919        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9920            .await
9921            .unwrap();
9922        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9923        assert_eq!(eligible.len(), 1);
9924        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9925        assert_eq!(
9926            eligible[0].get("branch").and_then(Value::as_str),
9927            Some("feature")
9928        );
9929    }
9930
9931    #[tokio::test]
9932    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9933    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9934        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
9935        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
9936        let (dir, head) = ready_worktree();
9937        let pr = format!(
9938            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9939        );
9940        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9941        let ghdir = tempfile::tempdir().unwrap();
9942        let guard = shim_lock();
9943        let bin = ghdir.path().join("fake-gh");
9944        write_exec_script(
9945            &bin,
9946            &format!(
9947                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
9948                enqueue =
9949                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9950            ),
9951        );
9952        let svc = WorktreesService::new();
9953        let req = MergeQueueRequest {
9954            paths: vec![dir.path().to_path_buf()],
9955            requester_key: Some("w1".into()),
9956            check: false,
9957            confirmed: true,
9958        };
9959        // `merge_queue_with` shells the freshly-written `fake-gh` shim (twice,
9960        // for the resolve then the enqueue) via `spawn_blocking` — a test-only
9961        // ETXTBSY exec race the `shim_lock` guard alone does not retry (see
9962        // test_support::shim's module docs).
9963        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
9964            .await
9965            .unwrap();
9966        drop(guard);
9967        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9968        assert_eq!(queued.len(), 1, "{reply}");
9969        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9970        assert!(reply
9971            .get("failed")
9972            .and_then(Value::as_array)
9973            .unwrap()
9974            .is_empty());
9975    }
9976
9977    #[tokio::test]
9978    async fn concurrent_closes_overlap_their_heartbeat_waits() {
9979        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9980        let svc = Arc::new(WorktreesService::new());
9981        // Two *different* windows own the two targets — the multi-select shape.
9982        for (key, path) in [("w2", &first), ("w3", &second)] {
9983            svc.handle("register", json!({ "key": key, "folders": [path] }))
9984                .await
9985                .unwrap();
9986        }
9987
9988        let spawn_close = |path: PathBuf| {
9989            let svc = svc.clone();
9990            tokio::spawn(async move {
9991                svc.handle(
9992                    "close",
9993                    json!({
9994                        "path": path,
9995                        "remove": true,
9996                        "confirmed": true,
9997                        "requester_key": "w1",
9998                    }),
9999                )
10000                .await
10001            })
10002        };
10003        let a = spawn_close(first.clone());
10004        let b = spawn_close(second.clone());
10005
10006        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
10007        // *both* windows are told to close while *neither* op has finished, so the
10008        // two multi-second heartbeat waits are in flight at once. Take the guard
10009        // before `await_windows_closed` instead of after and this fails — op B
10010        // would sit on the lock without ever marking w3, restoring exactly the
10011        // N-stacked-waits latency the fan-out exists to remove.
10012        for key in ["w2", "w3"] {
10013            let mut saw_close = false;
10014            for _ in 0..400 {
10015                let hb = svc
10016                    .handle("heartbeat", json!({ "key": key }))
10017                    .await
10018                    .unwrap();
10019                if hb.get("close").and_then(Value::as_bool) == Some(true) {
10020                    saw_close = true;
10021                    break;
10022                }
10023                tokio::time::sleep(Duration::from_millis(5)).await;
10024            }
10025            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10026        }
10027        assert!(
10028            !a.is_finished() && !b.is_finished(),
10029            "neither close can have finished: both windows are still registered"
10030        );
10031
10032        // Both windows close; both ops then remove.
10033        for key in ["w2", "w3"] {
10034            svc.handle("unregister", json!({ "key": key }))
10035                .await
10036                .unwrap();
10037        }
10038        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10039        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10040        assert!(!first.exists());
10041        assert!(!second.exists());
10042    }
10043
10044    #[tokio::test]
10045    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10046        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10047        let svc = WorktreesService::new();
10048        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
10049        // main, no risks → the extension proceeds with no dialog.
10050        let report = svc
10051            .handle("close", json!({ "path": wt_path, "remove": true }))
10052            .await
10053            .unwrap();
10054        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10055        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10056        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10057        assert!(report
10058            .get("risks")
10059            .and_then(Value::as_array)
10060            .unwrap()
10061            .is_empty());
10062        // No side effects: the worktree still exists.
10063        assert!(wt_path.exists());
10064    }
10065
10066    #[tokio::test]
10067    async fn close_removes_a_clean_linked_worktree() {
10068        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10069        let svc = WorktreesService::new();
10070        let reply = svc
10071            .handle(
10072                "close",
10073                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10074            )
10075            .await
10076            .unwrap();
10077        assert_eq!(reply, json!({ "removed": true }));
10078        assert!(
10079            !wt_path.exists(),
10080            "the worktree directory should be deleted"
10081        );
10082    }
10083
10084    // --- Close-op audit logging (#1364) ------------------------------------
10085
10086    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
10087    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
10088    /// `with_default`, so it never disturbs a global subscriber other tests set.
10089    /// The audit-line tests drive the sync helpers directly (no runtime, no
10090    /// `spawn_blocking`), so the captured events fire on this thread where the
10091    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
10092    /// under the parallel suite is *not* reliably captured this way.
10093    #[derive(Clone, Default)]
10094    struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10095
10096    impl std::io::Write for CaptureWriter {
10097        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10098            self.0.lock().unwrap().extend_from_slice(buf);
10099            Ok(buf.len())
10100        }
10101        fn flush(&mut self) -> std::io::Result<()> {
10102            Ok(())
10103        }
10104    }
10105
10106    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10107        type Writer = Self;
10108        fn make_writer(&'a self) -> Self::Writer {
10109            self.clone()
10110        }
10111    }
10112
10113    /// Runs `f` under a thread-local INFO-level subscriber and returns everything
10114    /// it logged. `f` must be fully synchronous on this thread.
10115    fn capture_info(f: impl FnOnce()) -> String {
10116        let writer = CaptureWriter::default();
10117        let subscriber = tracing_subscriber::fmt()
10118            .with_max_level(tracing::Level::INFO)
10119            .with_ansi(false)
10120            .with_writer(writer.clone())
10121            .finish();
10122        tracing::subscriber::with_default(subscriber, f);
10123        let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10124        logs
10125    }
10126
10127    // ── rebase op (#1415) ──────────────────────────────────────────────────
10128
10129    /// A `RebaseRequest` over `paths` with everything else defaulted.
10130    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10131        RebaseRequest {
10132            paths,
10133            requester_key: None,
10134            check: false,
10135            confirmed: false,
10136            keep_conflicts: false,
10137            autostash: false,
10138            onto: None,
10139        }
10140    }
10141
10142    /// A repo whose `main` has advanced one commit past a linked `feature`
10143    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
10144    ///
10145    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
10146    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
10147    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
10148    /// own concern and is covered in `worktree_rebase.rs`.
10149    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10150        let main_dir = tempfile::tempdir().unwrap();
10151        let repo = init_repo(main_dir.path());
10152        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10153        repo.set_head("refs/heads/main").unwrap();
10154        let wt_parent = tempfile::tempdir().unwrap();
10155        let wt_path = wt_parent.path().join("feature-wt");
10156        add_worktree(&repo, base, &wt_path, "feature");
10157        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
10158        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10159        (main_dir, wt_parent, wt_path)
10160    }
10161
10162    #[tokio::test]
10163    async fn rebase_with_refuses_an_empty_selection() {
10164        // A bare `rebase` must be a usage error, never a silent mass-rebase.
10165        let svc = WorktreesService::new();
10166        let err = svc
10167            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10168            .await
10169            .unwrap_err()
10170            .to_string();
10171        assert!(err.contains("at least one path"), "{err}");
10172    }
10173
10174    #[tokio::test]
10175    async fn rebase_with_phase_one_reports_without_rebasing() {
10176        let (_main, _parent, wt) = behind_worktree();
10177        let before = Repository::open(&wt).unwrap().head().unwrap().target();
10178
10179        let svc = WorktreesService::new();
10180        let reply = svc
10181            .rebase_with(
10182                RebaseRequest {
10183                    check: true,
10184                    onto: Some("main".into()),
10185                    ..rebase_req(vec![wt.clone()])
10186                },
10187                crate::git::resolve_git_binary(),
10188            )
10189            .await
10190            .unwrap();
10191
10192        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10193        assert_eq!(worktrees.len(), 1, "{reply}");
10194        assert_eq!(
10195            worktrees[0].get("status").and_then(Value::as_str),
10196            Some("would-rebase"),
10197            "{reply}"
10198        );
10199        // A local onto ref means no fetch was attempted at all.
10200        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10201        assert_eq!(fetches.len(), 1);
10202        assert_eq!(
10203            fetches[0].get("fetched").and_then(Value::as_bool),
10204            Some(false)
10205        );
10206        assert_eq!(
10207            Repository::open(&wt).unwrap().head().unwrap().target(),
10208            before,
10209            "phase 1 must not move the branch"
10210        );
10211    }
10212
10213    #[tokio::test]
10214    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10215        let (_main, _parent, wt) = behind_worktree();
10216        let svc = WorktreesService::new();
10217        let reply = svc
10218            .rebase_with(
10219                RebaseRequest {
10220                    confirmed: true,
10221                    onto: Some("main".into()),
10222                    ..rebase_req(vec![wt.clone()])
10223                },
10224                crate::git::resolve_git_binary(),
10225            )
10226            .await
10227            .unwrap();
10228
10229        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10230        assert_eq!(
10231            worktrees[0].get("status").and_then(Value::as_str),
10232            Some("rebased"),
10233            "{reply}"
10234        );
10235        // The transient cue is cleared on the way out, so no row keeps spinning.
10236        assert!(
10237            svc.registry.rebasing_paths().is_empty(),
10238            "the rebasing mark must be cleared after the execute"
10239        );
10240    }
10241
10242    #[tokio::test]
10243    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10244        // The re-validation that makes two-phase meaningful: a `confirmed` request
10245        // still runs the classifier, so a worktree that is dirty *now* is skipped
10246        // rather than rebased on the strength of an earlier phase-1 verdict.
10247        let (_main, _parent, wt) = behind_worktree();
10248        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10249
10250        let svc = WorktreesService::new();
10251        let reply = svc
10252            .rebase_with(
10253                RebaseRequest {
10254                    confirmed: true,
10255                    onto: Some("main".into()),
10256                    ..rebase_req(vec![wt.clone()])
10257                },
10258                crate::git::resolve_git_binary(),
10259            )
10260            .await
10261            .unwrap();
10262
10263        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10264        assert_eq!(
10265            worktrees[0].get("status").and_then(Value::as_str),
10266            Some("skipped"),
10267            "{reply}"
10268        );
10269        assert_eq!(
10270            worktrees[0].get("reason").and_then(Value::as_str),
10271            Some("dirty"),
10272            "{reply}"
10273        );
10274    }
10275
10276    #[tokio::test]
10277    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10278        // The hazard the plan-under-the-lock ordering exists to prevent: if another
10279        // run has left a worktree mid-rebase, this one must classify it as
10280        // `operation-in-progress` and leave it alone — never run `git rebase`
10281        // against it, which (without `keep_conflicts`) would `--abort` and destroy
10282        // the conflict resolution in progress.
10283        let (_main, _parent, wt) = behind_worktree();
10284        // Fake a rebase in progress the way git does: the state directory's
10285        // presence is what `Repository::state()` keys on.
10286        std::fs::create_dir_all(wt.join(".git")).ok();
10287        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10288        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10289        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10290        assert_ne!(
10291            Repository::open(&wt).unwrap().state(),
10292            RepositoryState::Clean,
10293            "precondition: the worktree looks mid-rebase to git2"
10294        );
10295
10296        let svc = WorktreesService::new();
10297        let reply = svc
10298            .rebase_with(
10299                RebaseRequest {
10300                    confirmed: true,
10301                    onto: Some("main".into()),
10302                    ..rebase_req(vec![wt.clone()])
10303                },
10304                crate::git::resolve_git_binary(),
10305            )
10306            .await
10307            .unwrap();
10308
10309        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10310        assert_eq!(
10311            worktrees[0].get("reason").and_then(Value::as_str),
10312            Some("operation-in-progress"),
10313            "{reply}"
10314        );
10315        // Still mid-rebase: nothing aborted it out from under whoever owns it.
10316        assert_ne!(
10317            Repository::open(&wt).unwrap().state(),
10318            RepositoryState::Clean
10319        );
10320    }
10321
10322    #[test]
10323    fn rebase_request_maps_onto_engine_options() {
10324        let req = RebaseRequest {
10325            keep_conflicts: true,
10326            autostash: true,
10327            onto: Some("origin/release".into()),
10328            ..rebase_req(vec![PathBuf::from("/wt")])
10329        };
10330        let opts = req.options(PathBuf::from("/custom/git"));
10331        assert!(opts.keep_conflicts && opts.autostash);
10332        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10333        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10334        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
10335        // engine's own flag stays off — setting it would be a second, redundant
10336        // gate that could silently no-op a confirmed execute.
10337        assert!(!opts.dry_run);
10338    }
10339
10340    #[test]
10341    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10342        let req = RebaseRequest {
10343            requester_key: Some("win-3".into()),
10344            check: true,
10345            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10346        };
10347        let plan = worktree_rebase::Plan {
10348            fetches: vec![worktree_rebase::FetchOutcome {
10349                repo_root: PathBuf::from("/repo"),
10350                onto: "origin/main".into(),
10351                fetched: true,
10352                ok: false,
10353                detail: Some("host unreachable".into()),
10354            }],
10355            worktrees: vec![
10356                worktree_rebase::WorktreeOutcome {
10357                    path: PathBuf::from("/a"),
10358                    branch: Some("a".into()),
10359                    onto: "origin/main".into(),
10360                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10361                },
10362                worktree_rebase::WorktreeOutcome {
10363                    path: PathBuf::from("/b"),
10364                    branch: Some("b".into()),
10365                    onto: "origin/main".into(),
10366                    result: worktree_rebase::RebaseResult::UpToDate,
10367                },
10368            ],
10369        };
10370        let logs = capture_info(|| log_rebase_check(&req, &plan));
10371        assert!(logs.contains("rebase check"), "{logs}");
10372        assert!(logs.contains("win-3"), "{logs}");
10373        assert!(logs.contains("requested=2"), "{logs}");
10374        assert!(logs.contains("pending=1"), "{logs}");
10375        assert!(logs.contains("failed_fetches=1"), "{logs}");
10376    }
10377
10378    #[test]
10379    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10380        // A CLI-style requester (no window key) logs the dash fallback.
10381        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10382        let outcome = |result| worktree_rebase::WorktreeOutcome {
10383            path: PathBuf::from("/x"),
10384            branch: Some("x".into()),
10385            onto: "origin/main".into(),
10386            result,
10387        };
10388        let outcomes = vec![
10389            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10390            outcome(worktree_rebase::RebaseResult::Conflict {
10391                detail: "CONFLICT".into(),
10392                left_in_place: true,
10393            }),
10394            outcome(worktree_rebase::RebaseResult::Skipped {
10395                reason: worktree_rebase::SkipReason::Dirty,
10396            }),
10397        ];
10398        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10399        assert!(logs.contains("rebase execute"), "{logs}");
10400        assert!(logs.contains("rebased=1"), "{logs}");
10401        assert!(logs.contains("conflicts=1"), "{logs}");
10402        assert!(logs.contains("left_in_place=1"), "{logs}");
10403        assert!(logs.contains("skipped=1"), "{logs}");
10404        assert!(logs.contains(r#"requester="-""#), "{logs}");
10405    }
10406
10407    // --- Push op (#1443) ---------------------------------------------------
10408
10409    /// A `PushRequest` with every field defaulted, for terse test construction.
10410    fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10411        PushRequest {
10412            paths,
10413            requester_key: None,
10414            check: false,
10415            confirmed: false,
10416        }
10417    }
10418
10419    /// A bare `origin`, a local clone of `main`, and a linked `feature` worktree
10420    /// whose branch is published and then **rewritten** — i.e. exactly what a
10421    /// rebase leaves behind, the state `push` exists for.
10422    ///
10423    /// Shells out to `git` (so the push has a real remote to talk to) under the
10424    /// shared serialization guard the other git-heavy tests use.
10425    fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10426        // Held for the fixture only — that dozen-subprocess burst is what the lock
10427        // exists to cap — and released on return, before any `.await` in the test.
10428        let _guard = crate::git::worktree_batch::test_serial_lock();
10429        let root = tempfile::tempdir().unwrap();
10430        let origin = root.path().join("origin.git");
10431        let local = root.path().join("local");
10432        let wt = root.path().join("feature-wt");
10433        std::fs::create_dir_all(&origin).unwrap();
10434        std::fs::create_dir_all(&local).unwrap();
10435
10436        let git = |dir: &Path, args: &[&str]| {
10437            let out = crate::git::worktree_batch::run_git_in(
10438                &crate::git::resolve_git_binary(),
10439                dir,
10440                args,
10441            )
10442            .unwrap();
10443            assert!(
10444                out.status.success(),
10445                "git {args:?} failed: {}",
10446                String::from_utf8_lossy(&out.stderr)
10447            );
10448        };
10449
10450        git(&origin, &["init", "--bare", "-b", "main"]);
10451        git(&local, &["init", "-b", "main"]);
10452        git(&local, &["config", "user.name", "Test"]);
10453        git(&local, &["config", "user.email", "test@example.com"]);
10454        git(&local, &["config", "commit.gpgsign", "false"]);
10455        std::fs::write(local.join("f.txt"), "base\n").unwrap();
10456        git(&local, &["add", "f.txt"]);
10457        git(&local, &["commit", "-m", "base"]);
10458        git(
10459            &local,
10460            &["remote", "add", "origin", origin.to_str().unwrap()],
10461        );
10462        git(&local, &["push", "-u", "origin", "main"]);
10463        git(
10464            &local,
10465            &[
10466                "worktree",
10467                "add",
10468                "-b",
10469                "feature",
10470                wt.to_str().unwrap(),
10471                "main",
10472            ],
10473        );
10474        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10475        git(&wt, &["add", "g.txt"]);
10476        git(&wt, &["commit", "-m", "work"]);
10477        git(&wt, &["push", "-u", "origin", "feature"]);
10478        // The rewrite: `feature` now diverges from `origin/feature`.
10479        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10480
10481        (root, origin, std::fs::canonicalize(&wt).unwrap())
10482    }
10483
10484    /// The tip of `refname` in the bare origin, when it exists.
10485    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10486        Repository::open_bare(origin)
10487            .unwrap()
10488            .refname_to_id(refname)
10489            .ok()
10490    }
10491
10492    #[tokio::test]
10493    async fn push_with_refuses_an_empty_selection() {
10494        // A bare `push` must be a usage error, never a silent mass-push.
10495        let svc = WorktreesService::new();
10496        let err = svc
10497            .push_with(push_req(Vec::new()), PathBuf::from("git"))
10498            .await
10499            .unwrap_err()
10500            .to_string();
10501        assert!(err.contains("at least one path"), "{err}");
10502    }
10503
10504    #[tokio::test]
10505    async fn push_with_phase_one_reports_without_publishing() {
10506        let (_root, origin, wt) = rewritten_worktree();
10507        let before = origin_tip(&origin, "refs/heads/feature");
10508
10509        let svc = WorktreesService::new();
10510        let reply = svc
10511            .push_with(
10512                PushRequest {
10513                    check: true,
10514                    ..push_req(vec![wt.clone()])
10515                },
10516                crate::git::resolve_git_binary(),
10517            )
10518            .await
10519            .unwrap();
10520
10521        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10522        assert_eq!(worktrees.len(), 1, "{reply}");
10523        assert_eq!(
10524            worktrees[0].get("status").and_then(Value::as_str),
10525            Some("would-force"),
10526            "{reply}"
10527        );
10528        assert!(
10529            reply.get("fetches").is_none(),
10530            "a push plan contacts no remote, so it reports no fetches: {reply}"
10531        );
10532        assert_eq!(
10533            origin_tip(&origin, "refs/heads/feature"),
10534            before,
10535            "phase 1 must publish nothing"
10536        );
10537        assert!(
10538            svc.registry.pushing_paths().is_empty(),
10539            "phase 1 must not mark a row as in flight"
10540        );
10541    }
10542
10543    #[tokio::test]
10544    async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10545        let (_root, origin, wt) = rewritten_worktree();
10546        let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10547
10548        let svc = WorktreesService::new();
10549        let reply = svc
10550            .push_with(
10551                PushRequest {
10552                    confirmed: true,
10553                    ..push_req(vec![wt.clone()])
10554                },
10555                crate::git::resolve_git_binary(),
10556            )
10557            .await
10558            .unwrap();
10559
10560        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10561        assert_eq!(
10562            worktrees[0].get("status").and_then(Value::as_str),
10563            Some("pushed"),
10564            "{reply}"
10565        );
10566        assert_eq!(
10567            worktrees[0].get("forced").and_then(Value::as_bool),
10568            Some(true),
10569            "a rewritten branch is published under the lease: {reply}"
10570        );
10571        assert_eq!(
10572            origin_tip(&origin, "refs/heads/feature"),
10573            rewritten,
10574            "the remote must carry the rewritten tip"
10575        );
10576        assert!(
10577            svc.registry.pushing_paths().is_empty(),
10578            "the pushing mark must be cleared after the execute — a push writes no \
10579             on-disk state, so nothing else could ever correct a leftover"
10580        );
10581    }
10582
10583    #[tokio::test]
10584    async fn push_resolves_the_git_binary_for_itself() {
10585        // The public entry point, which `push_with` exists to let the other tests
10586        // bypass. Safe to call for real: a `check` never reaches a subprocess at
10587        // all, because planning a push contacts no remote.
10588        let (_root, origin, wt) = rewritten_worktree();
10589        let before = origin_tip(&origin, "refs/heads/feature");
10590
10591        let svc = WorktreesService::new();
10592        let reply = svc
10593            .push(PushRequest {
10594                check: true,
10595                ..push_req(vec![wt])
10596            })
10597            .await
10598            .unwrap();
10599
10600        assert_eq!(
10601            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10602                .get("status")
10603                .and_then(Value::as_str),
10604            Some("would-force"),
10605            "{reply}"
10606        );
10607        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10608    }
10609
10610    #[tokio::test]
10611    async fn push_with_defaults_to_report_only_without_confirmation() {
10612        // Neither `check` nor `confirmed`: the safe reading is "report", matching
10613        // `rebase`. A client that forgets the flag must never publish.
10614        let (_root, origin, wt) = rewritten_worktree();
10615        let before = origin_tip(&origin, "refs/heads/feature");
10616
10617        let svc = WorktreesService::new();
10618        let reply = svc
10619            .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10620            .await
10621            .unwrap();
10622
10623        assert_eq!(
10624            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10625                .get("status")
10626                .and_then(Value::as_str),
10627            Some("would-force"),
10628            "{reply}"
10629        );
10630        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10631    }
10632
10633    #[tokio::test]
10634    async fn push_with_refuses_to_force_the_remote_default_branch() {
10635        // The gate that inverts ADR-0060, enforced in the daemon rather than only
10636        // in the UI: a rewritten `main` is reported, never published.
10637        let (root, origin, _wt) = rewritten_worktree();
10638        let local = root.path().join("local");
10639        let before = origin_tip(&origin, "refs/heads/main");
10640        crate::git::worktree_batch::run_git_in(
10641            &crate::git::resolve_git_binary(),
10642            &local,
10643            &["commit", "--amend", "-m", "rewritten main"],
10644        )
10645        .unwrap();
10646
10647        let svc = WorktreesService::new();
10648        let reply = svc
10649            .push_with(
10650                PushRequest {
10651                    confirmed: true,
10652                    ..push_req(vec![local.clone()])
10653                },
10654                crate::git::resolve_git_binary(),
10655            )
10656            .await
10657            .unwrap();
10658
10659        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10660        assert_eq!(
10661            worktrees[0].get("reason").and_then(Value::as_str),
10662            Some("default-branch-force-push"),
10663            "{reply}"
10664        );
10665        assert_eq!(
10666            origin_tip(&origin, "refs/heads/main"),
10667            before,
10668            "the default branch's published history must be untouched"
10669        );
10670    }
10671
10672    #[test]
10673    fn log_push_check_separates_the_force_count_from_the_pending_count() {
10674        let req = PushRequest {
10675            requester_key: Some("win-7".into()),
10676            check: true,
10677            ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10678        };
10679        let outcome = |result| worktree_push::WorktreeOutcome {
10680            path: PathBuf::from("/x"),
10681            branch: Some("x".into()),
10682            remote: "origin".into(),
10683            remote_branch: "x".into(),
10684            result,
10685        };
10686        let plan = worktree_push::Plan {
10687            worktrees: vec![
10688                outcome(worktree_push::PushResult::WouldForce {
10689                    ahead: 1,
10690                    behind: 1,
10691                }),
10692                outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10693                outcome(worktree_push::PushResult::Skipped {
10694                    reason: worktree_push::SkipReason::DefaultBranchForcePush,
10695                }),
10696            ],
10697        };
10698        let logs = capture_info(|| log_push_check(&req, &plan));
10699        assert!(logs.contains("push check"), "{logs}");
10700        assert!(logs.contains("pending=2"), "{logs}");
10701        assert!(logs.contains("forced=1"), "{logs}");
10702        assert!(logs.contains("skipped=1"), "{logs}");
10703        assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10704    }
10705
10706    #[test]
10707    fn log_push_execute_counts_lease_refusals_separately() {
10708        let req = push_req(vec![PathBuf::from("/a")]);
10709        let outcome = |result| worktree_push::WorktreeOutcome {
10710            path: PathBuf::from("/x"),
10711            branch: Some("x".into()),
10712            remote: "origin".into(),
10713            remote_branch: "x".into(),
10714            result,
10715        };
10716        let outcomes = vec![
10717            outcome(worktree_push::PushResult::Pushed { forced: true }),
10718            outcome(worktree_push::PushResult::Pushed { forced: false }),
10719            outcome(worktree_push::PushResult::Created),
10720            outcome(worktree_push::PushResult::Rejected {
10721                detail: "stale info".into(),
10722                stale: true,
10723            }),
10724            outcome(worktree_push::PushResult::Rejected {
10725                detail: "pre-receive hook declined".into(),
10726                stale: false,
10727            }),
10728        ];
10729        let logs = capture_info(|| log_push_execute(&req, &outcomes));
10730        assert!(logs.contains("push execute"), "{logs}");
10731        assert!(logs.contains("pushed=2"), "{logs}");
10732        assert!(logs.contains("forced=1"), "{logs}");
10733        assert!(logs.contains("created=1"), "{logs}");
10734        assert!(logs.contains("rejected=2"), "{logs}");
10735        assert!(
10736            logs.contains("stale_rejected=1"),
10737            "a lease refusal is the interesting half of a rejection: {logs}"
10738        );
10739    }
10740
10741    #[test]
10742    fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10743        let dir = tempfile::tempdir().unwrap();
10744        let path = canonical(dir.path());
10745
10746        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10747        assert!(!quiet.pushing);
10748        let json = serde_json::to_value(&quiet).unwrap();
10749        assert!(
10750            json.get("pushing").is_none(),
10751            "an idle row stays byte-identical for an older client: {json}"
10752        );
10753
10754        let busy = worktree_entry(
10755            &path,
10756            true,
10757            &HashMap::new(),
10758            &InFlight {
10759                pushing: [path.clone()].into(),
10760                rebasing: HashSet::new(),
10761            },
10762        );
10763        assert!(busy.pushing, "the registry's transient mark rides through");
10764        assert!(
10765            !busy.rebasing,
10766            "the two cues are independent — a push must not read as a rebase"
10767        );
10768        assert_eq!(
10769            serde_json::to_value(&busy).unwrap()["pushing"],
10770            serde_json::json!(true)
10771        );
10772    }
10773
10774    #[test]
10775    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10776        assert_eq!(operation_slug(RepositoryState::Clean), None);
10777        assert_eq!(
10778            operation_slug(RepositoryState::Rebase).as_deref(),
10779            Some("rebase")
10780        );
10781        assert_eq!(
10782            operation_slug(RepositoryState::RebaseMerge).as_deref(),
10783            Some("rebase"),
10784            "the merge-backend rebase is still just a rebase to the user"
10785        );
10786        assert_eq!(
10787            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10788            Some("rebase-interactive")
10789        );
10790        assert_eq!(
10791            operation_slug(RepositoryState::Merge).as_deref(),
10792            Some("merge")
10793        );
10794        assert_eq!(
10795            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10796            Some("cherry-pick")
10797        );
10798        assert_eq!(
10799            operation_slug(RepositoryState::RevertSequence).as_deref(),
10800            Some("revert")
10801        );
10802        assert_eq!(
10803            operation_slug(RepositoryState::Bisect).as_deref(),
10804            Some("bisect")
10805        );
10806        assert_eq!(
10807            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10808            Some("apply-mailbox")
10809        );
10810    }
10811
10812    #[test]
10813    fn git_status_omits_operation_for_a_clean_worktree() {
10814        let dir = tempfile::tempdir().unwrap();
10815        let _repo = diverging_repo(dir.path());
10816        assert_eq!(
10817            git_status(dir.path()).operation,
10818            None,
10819            "a clean worktree carries no operation, so the field stays off the wire"
10820        );
10821    }
10822
10823    #[test]
10824    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10825        let main_dir = tempfile::tempdir().unwrap();
10826        let repo = init_repo(main_dir.path());
10827        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10828        repo.set_head("refs/heads/main").unwrap();
10829        let path = canonical(main_dir.path());
10830
10831        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10832        assert!(!quiet.rebasing);
10833        // Byte-identical for a pre-#1415 client: neither new field is serialized.
10834        let json = serde_json::to_value(&quiet).unwrap();
10835        assert!(json.get("rebasing").is_none(), "{json}");
10836        assert!(json.get("operation").is_none(), "{json}");
10837
10838        let busy = worktree_entry(
10839            &path,
10840            true,
10841            &HashMap::new(),
10842            &InFlight {
10843                rebasing: std::iter::once(path.clone()).collect(),
10844                pushing: HashSet::new(),
10845            },
10846        );
10847        assert!(busy.rebasing, "the registry's transient mark rides through");
10848        assert_eq!(
10849            serde_json::to_value(&busy).unwrap()["rebasing"],
10850            serde_json::Value::Bool(true)
10851        );
10852    }
10853
10854    #[test]
10855    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10856        assert_eq!(note_kinds(&[]), "-");
10857        assert_eq!(
10858            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10859            "dirty,untracked"
10860        );
10861    }
10862
10863    #[test]
10864    fn is_self_close_true_only_when_requester_owns_an_open_window() {
10865        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10866        assert!(is_self_close(Some("w1"), &windows));
10867        assert!(
10868            !is_self_close(Some("w3"), &windows),
10869            "requester owns no window"
10870        );
10871        assert!(!is_self_close(None, &windows), "no requester");
10872        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10873    }
10874
10875    #[test]
10876    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10877        let logs = capture_info(|| {
10878            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10879            assert_eq!(reply, json!({ "removed": true }));
10880        });
10881        assert!(
10882            logs.contains("worktrees close: linked worktree pruned"),
10883            "a successful prune must log an INFO audit line, got: {logs}"
10884        );
10885        assert!(
10886            logs.contains("/wt/feature"),
10887            "the target path must ride the line, got: {logs}"
10888        );
10889    }
10890
10891    #[test]
10892    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10893        // The #1403 fix: an already-removed worktree still replies `removed: true`
10894        // (the row should go) but must NOT log the `pruned` line — it logs the
10895        // distinct `already-gone` outcome so the audit trail stops conflating the
10896        // two.
10897        let logs = capture_info(|| {
10898            let reply =
10899                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10900            assert_eq!(reply, json!({ "removed": true }));
10901        });
10902        assert!(
10903            logs.contains("worktrees close: nothing to prune, worktree already removed"),
10904            "an already-gone close must log its own outcome, got: {logs}"
10905        );
10906        assert!(
10907            !logs.contains("linked worktree pruned"),
10908            "an already-gone close must not claim it pruned, got: {logs}"
10909        );
10910    }
10911
10912    #[test]
10913    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10914        // ERROR is more severe than the INFO cap, so `capture_info` records it.
10915        let logs = capture_info(|| {
10916            let err = log_close_error(
10917                Path::new("/wt/feature"),
10918                "safety check",
10919                anyhow!("not a git worktree"),
10920            );
10921            assert_eq!(
10922                err.to_string(),
10923                "not a git worktree",
10924                "err propagates unchanged"
10925            );
10926        });
10927        assert!(
10928            logs.contains("worktrees close: safety check failed"),
10929            "a failed phase must log an ERROR audit line, got: {logs}"
10930        );
10931        assert!(
10932            logs.contains("not a git worktree"),
10933            "the cause must ride the line, got: {logs}"
10934        );
10935        assert!(
10936            logs.contains("/wt/feature"),
10937            "the target path must ride the line, got: {logs}"
10938        );
10939    }
10940
10941    #[test]
10942    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10943        let logs = capture_info(|| {
10944            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10945            assert!(err.is_err(), "a prune failure must propagate");
10946        });
10947        assert!(
10948            logs.contains("worktrees close: worktree prune failed"),
10949            "a prune failure must log a WARN audit line, got: {logs}"
10950        );
10951        assert!(
10952            logs.contains("locked"),
10953            "the failure cause must ride the line, got: {logs}"
10954        );
10955    }
10956
10957    #[test]
10958    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10959        let git = GitSafety {
10960            is_main: false,
10961            removable: true,
10962            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10963            info: vec![],
10964        };
10965        let logs = capture_info(|| {
10966            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10967        });
10968        assert!(
10969            logs.contains("worktrees close: safety check"),
10970            "phase-1 must log a safety-check line, got: {logs}"
10971        );
10972        assert!(
10973            logs.contains("/wt/feature"),
10974            "the path must ride the line, got: {logs}"
10975        );
10976        assert!(
10977            logs.contains("window_key=\"win-42\""),
10978            "the owning window key must ride the line, got: {logs}"
10979        );
10980        assert!(logs.contains("removable=true"), "got: {logs}");
10981        assert!(logs.contains("is_main=false"), "got: {logs}");
10982        assert!(logs.contains("open=true"), "got: {logs}");
10983        assert!(
10984            logs.contains("risks=dirty,untracked"),
10985            "the blocking risk kinds must ride the line, got: {logs}"
10986        );
10987    }
10988
10989    #[test]
10990    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10991        let git = GitSafety {
10992            is_main: false,
10993            removable: true,
10994            risks: vec![],
10995            info: vec![],
10996        };
10997        let logs = capture_info(|| {
10998            log_safety_check(Path::new("/wt/feature"), None, &git, false);
10999        });
11000        assert!(
11001            logs.contains("window_key=\"-\""),
11002            "no owning window → dash, got: {logs}"
11003        );
11004        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11005    }
11006
11007    #[test]
11008    fn log_executing_logs_the_routing_decision() {
11009        let logs = capture_info(|| {
11010            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11011        });
11012        assert!(
11013            logs.contains("worktrees close: executing"),
11014            "phase-2 must log the execute routing, got: {logs}"
11015        );
11016        assert!(
11017            logs.contains("requester=\"win-7\""),
11018            "the requester key must ride the line, got: {logs}"
11019        );
11020        assert!(logs.contains("remove=true"), "got: {logs}");
11021        assert!(logs.contains("self_close=false"), "got: {logs}");
11022        assert!(logs.contains("cross_window=3"), "got: {logs}");
11023    }
11024
11025    #[test]
11026    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11027        let logs = capture_info(|| {
11028            log_close_abort(
11029                Path::new("/wt/feature"),
11030                &anyhow!("window(s) did not close in time: win-9"),
11031            );
11032        });
11033        assert!(
11034            logs.contains("worktrees close: aborted"),
11035            "an abort must log a WARN audit line, got: {logs}"
11036        );
11037        assert!(
11038            logs.contains("/wt/feature"),
11039            "the path must ride the line, got: {logs}"
11040        );
11041        assert!(
11042            logs.contains("win-9"),
11043            "the still-open window must ride the line, got: {logs}"
11044        );
11045    }
11046
11047    #[test]
11048    fn log_window_closed_logs_the_no_removal_outcome() {
11049        let logs = capture_info(|| {
11050            log_window_closed(Path::new("/wt/feature"));
11051        });
11052        assert!(
11053            logs.contains("worktrees close: window closed, no removal"),
11054            "a remove:false close must log the no-removal outcome, got: {logs}"
11055        );
11056        assert!(
11057            logs.contains("/wt/feature"),
11058            "the path must ride the line, got: {logs}"
11059        );
11060    }
11061
11062    #[test]
11063    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11064        // The reorder (#1315) must still fully remove a worktree: both the
11065        // checked-out directory *and* the admin metadata git tracks it by, so it
11066        // no longer appears in `Repository::worktrees()`.
11067        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11068        let admin = main.path().join(".git").join("worktrees").join("feature");
11069        assert!(admin.exists(), "admin metadata should exist before removal");
11070
11071        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11072
11073        assert!(!wt_path.exists(), "the working directory should be gone");
11074        assert!(!admin.exists(), "the admin metadata should be pruned");
11075        let main_repo = Repository::open(main.path()).unwrap();
11076        assert_eq!(
11077            main_repo.worktrees().unwrap().len(),
11078            0,
11079            "git should no longer track the worktree"
11080        );
11081    }
11082
11083    #[test]
11084    fn remove_worktree_recovers_a_half_removed_orphan() {
11085        // The exact #1315 leftover: the old ordering deleted the admin metadata
11086        // first, then failed to rmdir the working tree, orphaning the directory
11087        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
11088        // rather than error with "not a git worktree".
11089        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11090        let admin = main.path().join(".git").join("worktrees").join("feature");
11091        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
11092        std::fs::remove_dir_all(&admin).unwrap();
11093        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11094        assert!(
11095            Repository::open(&wt_path).is_err(),
11096            "the orphan should not open as a repo"
11097        );
11098
11099        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11100        assert!(
11101            !wt_path.exists(),
11102            "the leftover directory should be removed"
11103        );
11104    }
11105
11106    /// A linked worktree whose working directory has been deleted out-of-band,
11107    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
11108    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
11109    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
11110    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
11111    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
11112    /// admin dir)`.
11113    fn orphaned_admin_worktree() -> (
11114        tempfile::TempDir,
11115        PathBuf,
11116        tempfile::TempDir,
11117        PathBuf,
11118        PathBuf,
11119    ) {
11120        let main_dir = tempfile::tempdir().unwrap();
11121        let main_root = main_dir.path().canonicalize().unwrap();
11122        let repo = init_repo(&main_root);
11123        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11124        repo.set_head("refs/heads/trunk").unwrap();
11125        let wt_parent = tempfile::tempdir().unwrap();
11126        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11127        add_worktree(&repo, a, &wt_path, "feature");
11128        let admin = main_root.join(".git").join("worktrees").join("feature");
11129        assert!(admin.exists(), "admin metadata exists before the orphaning");
11130        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
11131        std::fs::remove_dir_all(&wt_path).unwrap();
11132        (main_dir, main_root, wt_parent, wt_path, admin)
11133    }
11134
11135    fn window_on(folder: &Path) -> WindowEntry {
11136        WindowEntry {
11137            key: "w".to_string(),
11138            folders: vec![folder.to_path_buf()],
11139            repo: None,
11140            title: None,
11141            pid: None,
11142            last_seen: Utc::now(),
11143        }
11144    }
11145
11146    #[test]
11147    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11148        // #1403: working tree gone, admin present. An external worktree shares no
11149        // ancestor with its repo, so the owner is found via a live window on the
11150        // main repo — the same window whose repo enumeration showed the stuck row.
11151        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11152
11153        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11154
11155        assert_eq!(
11156            removed,
11157            Removal::Pruned,
11158            "the orphaned admin must be pruned"
11159        );
11160        assert!(!admin.exists(), "the admin metadata should be gone");
11161        let main_repo = Repository::open(&main_root).unwrap();
11162        assert!(
11163            main_repo.worktrees().unwrap().is_empty(),
11164            "git should no longer track the orphaned worktree"
11165        );
11166    }
11167
11168    #[test]
11169    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11170        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
11171        // worktrees/<name>` shape that produced the reported orphans) is located
11172        // by walking the gone path's ancestors — no registered window needed.
11173        let main_dir = tempfile::tempdir().unwrap();
11174        let main_root = main_dir.path().canonicalize().unwrap();
11175        let repo = init_repo(&main_root);
11176        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11177        repo.set_head("refs/heads/trunk").unwrap();
11178        // git2's `worktree()` creates the leaf but not intermediate parents.
11179        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11180        let wt_path = main_root.join(".nested").join("feature-wt");
11181        add_worktree(&repo, a, &wt_path, "feature");
11182        let admin = main_root.join(".git").join("worktrees").join("feature");
11183        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11184
11185        let removed = remove_worktree(&wt_path, &[]).unwrap();
11186
11187        assert_eq!(removed, Removal::Pruned);
11188        assert!(!admin.exists(), "the admin metadata should be gone");
11189    }
11190
11191    #[test]
11192    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11193        // The other side of #1403: working tree gone AND admin already pruned. No
11194        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
11195        // never a `pruned` lie.
11196        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11197        // Prune the admin entry too, so nothing remains to remove.
11198        std::fs::remove_dir_all(&admin).unwrap();
11199
11200        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11201
11202        assert_eq!(removed, Removal::AlreadyGone);
11203    }
11204
11205    #[test]
11206    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11207        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11208        // External worktree: no ancestor is the repo, so only the window feed finds
11209        // it. The main root rides through, deduped to a single entry.
11210        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11211        assert!(
11212            roots.contains(&main_root),
11213            "the owning main repo must be a candidate, got: {roots:?}"
11214        );
11215    }
11216
11217    #[test]
11218    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11219        // A candidate root that does not open as a repo (a stale registry folder,
11220        // a deleted repo) is skipped rather than fatal; the real owner that
11221        // follows still prunes.
11222        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11223        let junk = tempfile::tempdir().unwrap();
11224
11225        let removed =
11226            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11227
11228        assert_eq!(removed, Removal::Pruned);
11229        assert!(
11230            !admin.exists(),
11231            "the real owner must still prune the orphan"
11232        );
11233    }
11234
11235    #[test]
11236    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11237        // A candidate that opens as a repo but is a *linked* worktree carries no
11238        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
11239        // is the real owner. (A live sibling worktree is exactly such a candidate.)
11240        let main_dir = tempfile::tempdir().unwrap();
11241        let main_root = main_dir.path().canonicalize().unwrap();
11242        let repo = init_repo(&main_root);
11243        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11244        repo.set_head("refs/heads/trunk").unwrap();
11245        let wt_parent = tempfile::tempdir().unwrap();
11246        let wt_root = wt_parent.path().canonicalize().unwrap();
11247        let orphan = wt_root.join("orphan-wt");
11248        let sibling = wt_root.join("sibling-wt");
11249        add_worktree(&repo, a, &orphan, "orphan");
11250        add_worktree(&repo, a, &sibling, "sibling");
11251        let admin = main_root.join(".git").join("worktrees").join("orphan");
11252        std::fs::remove_dir_all(&orphan).unwrap();
11253
11254        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
11255        // main repo second (the actual owner).
11256        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11257
11258        assert_eq!(removed, Removal::Pruned);
11259        assert!(
11260            !admin.exists(),
11261            "the orphan's admin metadata must be pruned"
11262        );
11263    }
11264
11265    #[test]
11266    fn prune_orphaned_admin_refuses_a_locked_orphan() {
11267        // Locking is an admin-dir file, independent of the (gone) checkout, so an
11268        // orphaned worktree can still be locked. The prune must refuse it —
11269        // "unlock first" — rather than force past, mirroring the live path.
11270        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11271        let main_repo = Repository::open(&main_root).unwrap();
11272        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11273        main_repo
11274            .find_worktree(&name)
11275            .unwrap()
11276            .lock(Some("in use"))
11277            .unwrap();
11278
11279        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11280
11281        assert!(
11282            err.to_string().contains("locked"),
11283            "a locked orphan must be refused, got: {err:#}"
11284        );
11285        assert!(admin.exists(), "a refused prune must leave the admin entry");
11286    }
11287
11288    #[test]
11289    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11290        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11291        // A live worktree: gitlink resolves → not an orphan.
11292        assert!(!is_orphaned_worktree(&wt_path));
11293        // The main checkout has a `.git` directory → not an orphan.
11294        assert!(!is_orphaned_worktree(main.path()));
11295        // Drop the admin metadata → the gitlink now dangles → orphan.
11296        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11297            .unwrap();
11298        assert!(is_orphaned_worktree(&wt_path));
11299    }
11300
11301    #[test]
11302    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11303        let tmp = tempfile::tempdir().unwrap();
11304        let missing = tmp.path().join("gone");
11305        assert!(remove_dir_all_retrying(&missing).is_ok());
11306    }
11307
11308    #[test]
11309    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11310        use std::io::Error;
11311        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11312            assert!(
11313                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11314                "errno {errno} is the concurrent-writer race and must be retried"
11315            );
11316        }
11317        // A hard failure must surface immediately rather than burn the backoff
11318        // waiting for a condition that will never clear.
11319        for errno in [
11320            nix::libc::EACCES,
11321            nix::libc::EPERM,
11322            nix::libc::EROFS,
11323            nix::libc::ENOTDIR,
11324        ] {
11325            assert!(
11326                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11327                "errno {errno} is permanent and must not be retried"
11328            );
11329        }
11330        // Not from the OS at all, so there is no errno to classify.
11331        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11332    }
11333
11334    #[test]
11335    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11336        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
11337        // race, so it must fail on the first attempt with the original cause
11338        // attached, leaving the path untouched.
11339        let tmp = tempfile::tempdir().unwrap();
11340        let file = tmp.path().join("not-a-directory");
11341        std::fs::write(&file, b"x").unwrap();
11342
11343        let mut attempts = 0;
11344        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11345            attempts += 1;
11346            std::fs::remove_dir_all(&file)
11347        })
11348        .unwrap_err();
11349
11350        assert_eq!(attempts, 1, "a permanent error must not be retried");
11351        assert!(
11352            err.to_string()
11353                .contains("failed to remove worktree directory"),
11354            "unexpected error: {err:#}"
11355        );
11356        assert!(err.source().is_some(), "the io::Error cause is preserved");
11357        assert!(file.exists());
11358    }
11359
11360    #[test]
11361    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11362        // A writer that never quiesces: every sweep re-finds the directory
11363        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
11364        // than the loop spinning forever.
11365        let tmp = tempfile::tempdir().unwrap();
11366        let mut attempts = 0;
11367        let backoff = [Duration::ZERO, Duration::ZERO];
11368        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11369            attempts += 1;
11370            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11371        })
11372        .unwrap_err();
11373
11374        // One attempt per delay, plus the initial one.
11375        assert_eq!(attempts, backoff.len() + 1);
11376        assert!(
11377            err.to_string()
11378                .contains("failed to remove worktree directory"),
11379            "unexpected error: {err:#}"
11380        );
11381    }
11382
11383    #[test]
11384    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11385        // The #1315 happy path, deterministically: the race clears partway through
11386        // the schedule and the removal then succeeds.
11387        let tmp = tempfile::tempdir().unwrap();
11388        let mut attempts = 0;
11389        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11390            attempts += 1;
11391            if attempts < 3 {
11392                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11393            } else {
11394                Ok(())
11395            }
11396        });
11397        assert!(result.is_ok(), "{result:?}");
11398        assert_eq!(attempts, 3);
11399    }
11400
11401    #[test]
11402    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11403        // A `.git` file that is readable but carries no `gitdir:` pointer is not
11404        // something we may delete.
11405        let tmp = tempfile::tempdir().unwrap();
11406        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11407        assert!(!is_orphaned_worktree(tmp.path()));
11408    }
11409
11410    #[test]
11411    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11412        // Neither a repo nor an orphan: refuse it rather than recursively deleting
11413        // whatever directory was passed in.
11414        let tmp = tempfile::tempdir().unwrap();
11415        let plain = tmp.path().join("plain");
11416        std::fs::create_dir(&plain).unwrap();
11417
11418        let err = remove_worktree(&plain, &[]).unwrap_err();
11419
11420        assert!(
11421            err.to_string().contains("not a git worktree"),
11422            "unexpected error: {err:#}"
11423        );
11424        assert!(plain.exists(), "a non-worktree path must be left alone");
11425    }
11426
11427    #[test]
11428    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11429        // Acceptance criterion (#1315): a language server / cargo still writing
11430        // into `target/` as the window closes makes the recursive rmdir race with
11431        // "Directory not empty". A background thread reproduces that by
11432        // repopulating `target/` for a bounded window; removal must retry past it
11433        // and still succeed once the writer stops.
11434        use std::sync::atomic::{AtomicBool, Ordering};
11435        use std::sync::Arc;
11436
11437        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11438        // Created once, here — never inside the writer loop. `create_dir_all`
11439        // rebuilds every *parent* component, so calling it per iteration let the
11440        // writer resurrect the worktree root the instant removal won the race,
11441        // failing the final assertion on a directory removal had correctly
11442        // deleted and the test itself put back (#1410).
11443        let nested = wt_path.join("target").join("nested");
11444        std::fs::create_dir_all(&nested).unwrap();
11445
11446        let stop = Arc::new(AtomicBool::new(false));
11447        let writer_stop = Arc::clone(&stop);
11448        let writer_dir = nested;
11449        let writer = std::thread::spawn(move || {
11450            let mut n = 0u64;
11451            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
11452            // stop so a later removal pass finds the directory quiescent.
11453            let deadline = std::time::Instant::now() + Duration::from_millis(400);
11454            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11455                // Best-effort, and deliberately creating no directory: `fs::write`
11456                // is `File::create`, which never makes parents. While `target/`
11457                // survives these keep it non-empty — the ENOTEMPTY removal has to
11458                // retry past — and once removal wins they simply fail with ENOENT.
11459                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11460                n += 1;
11461            }
11462        });
11463
11464        let result = remove_worktree(&wt_path, &[]);
11465        stop.store(true, Ordering::Relaxed);
11466        writer.join().unwrap();
11467
11468        assert!(
11469            result.is_ok(),
11470            "removal should retry past the writer: {result:?}"
11471        );
11472        assert!(!wt_path.exists(), "the worktree directory should be gone");
11473    }
11474
11475    #[tokio::test]
11476    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11477        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11478        // An untracked file in the worktree would be lost on removal.
11479        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11480
11481        let svc = WorktreesService::new();
11482        let report = svc
11483            .handle("close", json!({ "path": wt_path, "remove": true }))
11484            .await
11485            .unwrap();
11486        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11487        assert!(
11488            risks
11489                .iter()
11490                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11491            "expected an untracked risk: {report}"
11492        );
11493        // Still removable — the risk only means "confirm first", not "refuse".
11494        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11495        // The unconfirmed check has no side effects.
11496        assert!(wt_path.exists());
11497    }
11498
11499    #[tokio::test]
11500    async fn close_confirmed_removes_a_dirty_worktree() {
11501        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11502        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11503        let svc = WorktreesService::new();
11504        // With confirmation, the risks are overridden and removal proceeds.
11505        let reply = svc
11506            .handle(
11507                "close",
11508                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11509            )
11510            .await
11511            .unwrap();
11512        assert_eq!(reply, json!({ "removed": true }));
11513        assert!(!wt_path.exists());
11514    }
11515
11516    #[tokio::test]
11517    async fn close_refuses_to_remove_the_main_working_tree() {
11518        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11519        let svc = WorktreesService::new();
11520        // Phase 1: the main tree reports not-removable, marked main.
11521        let report = svc
11522            .handle("close", json!({ "path": main.path(), "remove": true }))
11523            .await
11524            .unwrap();
11525        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11526        assert_eq!(
11527            report.get("removable").and_then(Value::as_bool),
11528            Some(false)
11529        );
11530        // Phase 2: even a confirmed delete of the main tree is refused
11531        // defensively, and the directory is untouched.
11532        assert!(svc
11533            .handle(
11534                "close",
11535                json!({ "path": main.path(), "remove": true, "confirmed": true }),
11536            )
11537            .await
11538            .is_err());
11539        assert!(main.path().exists());
11540    }
11541
11542    #[tokio::test]
11543    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11544        // The case a naive impl would wrongly protect: a linked worktree checked
11545        // out on `main` (the default branch) is *still a linked worktree*, so it
11546        // is fully deletable — and `main` survives (removal never deletes a branch).
11547        let main_dir = tempfile::tempdir().unwrap();
11548        let repo = init_repo(main_dir.path());
11549        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11550        repo.set_head("refs/heads/trunk").unwrap();
11551        let wt_parent = tempfile::tempdir().unwrap();
11552        let wt_path = wt_parent.path().join("main-wt");
11553        add_worktree(&repo, a, &wt_path, "main");
11554
11555        let svc = WorktreesService::new();
11556        let reply = svc
11557            .handle(
11558                "close",
11559                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11560            )
11561            .await
11562            .unwrap();
11563        assert_eq!(reply, json!({ "removed": true }));
11564        assert!(!wt_path.exists());
11565        // The `main` branch is untouched by the worktree removal.
11566        assert!(
11567            repo.find_branch("main", git2::BranchType::Local).is_ok(),
11568            "the default branch must survive worktree removal"
11569        );
11570    }
11571
11572    #[tokio::test]
11573    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11574        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11575        let svc = WorktreesService::new();
11576        // First removal succeeds.
11577        svc.handle(
11578            "close",
11579            json!({ "path": wt_path, "remove": true, "confirmed": true }),
11580        )
11581        .await
11582        .unwrap();
11583        // A second confirmed close of the now-missing path is a clean success,
11584        // not an error (a stale snapshot must not crash).
11585        let reply = svc
11586            .handle(
11587                "close",
11588                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11589            )
11590            .await
11591            .unwrap();
11592        assert_eq!(reply, json!({ "removed": true }));
11593    }
11594
11595    #[tokio::test]
11596    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11597        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
11598        // behind so the tree view keeps showing a `prunable` row. Closing it must
11599        // actually prune the admin metadata (via the registered window's repo), not
11600        // report a `pruned` no-op that leaves the row stuck.
11601        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11602        let svc = WorktreesService::new();
11603        // A live window on the main repo — the vantage point the stuck row is
11604        // enumerated from, and the one the prune locates the owner through.
11605        svc.handle(
11606            "register",
11607            register_payload("main-w", None, &main_root.display().to_string()),
11608        )
11609        .await
11610        .unwrap();
11611
11612        // Before: the orphaned linked worktree still shows alongside the main tree.
11613        let before = svc.handle("tree", Value::Null).await.unwrap();
11614        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11615        assert_eq!(
11616            worktrees_before, 2,
11617            "the orphaned row is present before close"
11618        );
11619
11620        let reply = svc
11621            .handle(
11622                "close",
11623                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11624            )
11625            .await
11626            .unwrap();
11627        assert_eq!(reply, json!({ "removed": true }));
11628
11629        // After: admin metadata pruned and the row is gone from the tree view.
11630        assert!(!admin.exists(), "the admin metadata must be pruned");
11631        let after = svc.handle("tree", Value::Null).await.unwrap();
11632        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11633        assert_eq!(worktrees_after, 1, "only the main working tree remains");
11634    }
11635
11636    #[tokio::test]
11637    async fn close_safety_check_detects_detached_head_unreachable_commits() {
11638        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11639        // In the worktree, commit onto a detached HEAD so the new commit is
11640        // reachable from no ref — it would be GC'd on removal.
11641        let wt_repo = Repository::open(&wt_path).unwrap();
11642        let parent_oid = wt_repo.head().unwrap().target().unwrap();
11643        let parent = wt_repo.find_commit(parent_oid).unwrap();
11644        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11645        wt_repo.set_head_detached(orphan).unwrap();
11646
11647        let svc = WorktreesService::new();
11648        let report = svc
11649            .handle("close", json!({ "path": wt_path, "remove": true }))
11650            .await
11651            .unwrap();
11652        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11653        assert!(
11654            risks
11655                .iter()
11656                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11657            "expected an unreachable-commits risk: {report}"
11658        );
11659    }
11660
11661    #[tokio::test]
11662    async fn close_self_close_removes_when_the_requester_owns_the_target() {
11663        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11664        let svc = WorktreesService::new();
11665        // The requesting window itself has the worktree open: it is the only
11666        // owning window, so there is nothing to wait on — remove and reply, and
11667        // the extension closes its own window on `ok`.
11668        svc.handle(
11669            "register",
11670            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11671        )
11672        .await
11673        .unwrap();
11674        let reply = svc
11675            .handle(
11676                "close",
11677                json!({
11678                    "path": wt_path,
11679                    "remove": true,
11680                    "confirmed": true,
11681                    "requester_key": "w1",
11682                }),
11683            )
11684            .await
11685            .unwrap();
11686        assert_eq!(reply, json!({ "removed": true }));
11687        assert!(!wt_path.exists());
11688    }
11689
11690    #[tokio::test]
11691    async fn close_safety_check_surfaces_the_owning_window() {
11692        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11693        let svc = WorktreesService::new();
11694        // A multi-root window owns the target: the report surfaces its key and
11695        // folder count so the extension can warn "all N folders will close".
11696        svc.handle(
11697            "register",
11698            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11699        )
11700        .await
11701        .unwrap();
11702        let report = svc
11703            .handle("close", json!({ "path": wt_path, "remove": true }))
11704            .await
11705            .unwrap();
11706        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11707        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11708        assert_eq!(
11709            report.get("window_folder_count").and_then(Value::as_u64),
11710            Some(2)
11711        );
11712    }
11713
11714    #[tokio::test]
11715    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11716        let svc = WorktreesService::new();
11717        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11718            .await
11719            .unwrap();
11720        // No directive → a plain `{ known: true }`, byte-identical to before.
11721        assert_eq!(
11722            svc.handle("heartbeat", json!({ "key": "w1" }))
11723                .await
11724                .unwrap(),
11725            json!({ "known": true })
11726        );
11727        // Marked → the next heartbeat carries `close: true`, exactly once.
11728        svc.registry.mark_close_pending("w1");
11729        assert_eq!(
11730            svc.handle("heartbeat", json!({ "key": "w1" }))
11731                .await
11732                .unwrap(),
11733            json!({ "known": true, "close": true })
11734        );
11735        assert_eq!(
11736            svc.handle("heartbeat", json!({ "key": "w1" }))
11737                .await
11738                .unwrap(),
11739            json!({ "known": true })
11740        );
11741    }
11742
11743    // --- Reload op (#1417) -------------------------------------------------
11744
11745    #[tokio::test]
11746    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11747        let svc = WorktreesService::new();
11748        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11749            .await
11750            .unwrap();
11751        // Nothing pending → `reload` is absent, so a companion that predates
11752        // #1417 sees a byte-identical reply.
11753        assert_eq!(
11754            svc.handle("heartbeat", json!({ "key": "w1" }))
11755                .await
11756                .unwrap(),
11757            json!({ "known": true })
11758        );
11759        // Marked → the next heartbeat carries `reload: true`, exactly once.
11760        svc.registry.mark_reload_pending("w1");
11761        assert_eq!(
11762            svc.handle("heartbeat", json!({ "key": "w1" }))
11763                .await
11764                .unwrap(),
11765            json!({ "known": true, "reload": true })
11766        );
11767        assert_eq!(
11768            svc.handle("heartbeat", json!({ "key": "w1" }))
11769                .await
11770                .unwrap(),
11771            json!({ "known": true })
11772        );
11773    }
11774
11775    #[tokio::test]
11776    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11777        let svc = WorktreesService::new();
11778        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11779            .await
11780            .unwrap();
11781        // Independent `if`s, not an `else`: both fields ride the same reply and
11782        // both are consumed, so neither directive can be stranded by the other.
11783        // The companion resolves the collision by checking `close` first.
11784        svc.registry.mark_close_pending("w1");
11785        svc.registry.mark_reload_pending("w1");
11786        assert_eq!(
11787            svc.handle("heartbeat", json!({ "key": "w1" }))
11788                .await
11789                .unwrap(),
11790            json!({ "known": true, "close": true, "reload": true })
11791        );
11792        assert_eq!(
11793            svc.handle("heartbeat", json!({ "key": "w1" }))
11794                .await
11795                .unwrap(),
11796            json!({ "known": true })
11797        );
11798    }
11799
11800    #[tokio::test]
11801    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11802        let svc = WorktreesService::new();
11803        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11804            .await
11805            .unwrap();
11806        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11807            .await
11808            .unwrap();
11809
11810        // A key with no live window is reported, never an error: a window
11811        // closing between the client listing and sending is routine.
11812        let reply = svc
11813            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11814            .await
11815            .unwrap();
11816        assert_eq!(
11817            reply,
11818            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11819        );
11820
11821        // Both live targets now have a directive waiting; the unknown one does
11822        // not (the daemon must not resurrect a key it never knew).
11823        assert!(svc.registry.take_reload_pending("w1"));
11824        assert!(svc.registry.take_reload_pending("w2"));
11825        assert!(!svc.registry.take_reload_pending("ghost"));
11826    }
11827
11828    #[tokio::test]
11829    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11830        let svc = WorktreesService::new();
11831        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11832            .await
11833            .unwrap();
11834
11835        // A client repeating a key asks for one reload, not two — `requested`
11836        // counts distinct targets so the client's summary cannot overstate.
11837        assert_eq!(
11838            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11839                .await
11840                .unwrap(),
11841            json!({ "requested": 1, "signalled": 1, "unknown": [] })
11842        );
11843
11844        // An empty batch is a no-op success, and a missing field is an empty
11845        // batch — the callers filter their targets before sending.
11846        assert_eq!(
11847            svc.handle("reload", json!({ "target_keys": [] }))
11848                .await
11849                .unwrap(),
11850            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11851        );
11852        assert_eq!(
11853            svc.handle("reload", json!({})).await.unwrap(),
11854            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11855        );
11856    }
11857
11858    #[tokio::test]
11859    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11860        let svc = WorktreesService::new();
11861        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11862            .await
11863            .unwrap();
11864        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11865            .await
11866            .unwrap();
11867
11868        // The end-to-end contract: `reload` marks, the target's own heartbeat
11869        // delivers. Unlike `close`, nothing waits — the op has already returned.
11870        svc.handle("reload", json!({ "target_keys": ["w2"] }))
11871            .await
11872            .unwrap();
11873        assert_eq!(
11874            svc.handle("heartbeat", json!({ "key": "w2" }))
11875                .await
11876                .unwrap(),
11877            json!({ "known": true, "reload": true })
11878        );
11879        // A window that was not a target is untouched.
11880        assert_eq!(
11881            svc.handle("heartbeat", json!({ "key": "w1" }))
11882                .await
11883                .unwrap(),
11884            json!({ "known": true })
11885        );
11886    }
11887
11888    #[tokio::test]
11889    async fn reload_op_treats_an_unregistered_window_as_unknown() {
11890        let svc = WorktreesService::new();
11891        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11892            .await
11893            .unwrap();
11894        svc.handle("unregister", json!({ "key": "w1" }))
11895            .await
11896            .unwrap();
11897        // `list()` reaps on read, so a window that has gone away cannot be
11898        // signalled — it is reported instead.
11899        assert_eq!(
11900            svc.handle("reload", json!({ "target_keys": ["w1"] }))
11901                .await
11902                .unwrap(),
11903            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11904        );
11905    }
11906
11907    #[tokio::test]
11908    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11909        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11910        let svc = Arc::new(WorktreesService::new());
11911        // A *different* window (not the requester) owns the target.
11912        svc.handle(
11913            "register",
11914            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11915        )
11916        .await
11917        .unwrap();
11918
11919        // Drive the destructive close concurrently: it marks w2 to close and
11920        // waits for it to unregister before removing.
11921        let svc2 = svc.clone();
11922        let path = wt_path.clone();
11923        let close = tokio::spawn(async move {
11924            svc2.handle(
11925                "close",
11926                json!({
11927                    "path": path,
11928                    "remove": true,
11929                    "confirmed": true,
11930                    "requester_key": "w1",
11931                }),
11932            )
11933            .await
11934        });
11935
11936        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
11937        // closes its window and unregisters. Poll until the directive appears.
11938        let mut saw_close = false;
11939        for _ in 0..200 {
11940            let hb = svc
11941                .handle("heartbeat", json!({ "key": "w2" }))
11942                .await
11943                .unwrap();
11944            if hb.get("close").and_then(Value::as_bool) == Some(true) {
11945                saw_close = true;
11946                svc.handle("unregister", json!({ "key": "w2" }))
11947                    .await
11948                    .unwrap();
11949                break;
11950            }
11951            tokio::time::sleep(Duration::from_millis(5)).await;
11952        }
11953        assert!(saw_close, "w2 should have been told to close");
11954
11955        // Once w2 has unregistered, the close op removes the worktree.
11956        let reply = close.await.unwrap().unwrap();
11957        assert_eq!(reply, json!({ "removed": true }));
11958        assert!(!wt_path.exists());
11959    }
11960
11961    #[tokio::test]
11962    async fn await_windows_closed_times_out_when_a_window_never_closes() {
11963        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11964        let svc = WorktreesService::new();
11965        svc.handle(
11966            "register",
11967            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11968        )
11969        .await
11970        .unwrap();
11971        // The owning window never unregisters: the wait gives up (with a short
11972        // timeout here) rather than block, and names the still-open window.
11973        let err = await_windows_closed(
11974            &svc.registry,
11975            &wt_path,
11976            Some("w1"),
11977            Duration::from_millis(150),
11978            Duration::from_millis(25),
11979        )
11980        .await
11981        .unwrap_err();
11982        assert!(
11983            err.to_string().contains("w2"),
11984            "error names the window: {err}"
11985        );
11986        // The requester itself is excluded, so a self-only owner returns at once.
11987        await_windows_closed(
11988            &svc.registry,
11989            &wt_path,
11990            Some("w2"),
11991            Duration::from_millis(150),
11992            Duration::from_millis(25),
11993        )
11994        .await
11995        .unwrap();
11996    }
11997
11998    #[tokio::test]
11999    async fn close_window_without_remove_replies_closed_and_never_deletes() {
12000        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12001        let svc = WorktreesService::new();
12002        // "Close Window" on the main tree: no git inspection, no removal.
12003        let reply = svc
12004            .handle("close", json!({ "path": main.path(), "remove": false }))
12005            .await
12006            .unwrap();
12007        assert_eq!(reply, json!({ "closed": true }));
12008        assert!(main.path().exists());
12009    }
12010
12011    #[tokio::test]
12012    async fn close_safety_check_flags_modified_tracked_files() {
12013        // A tracked file, checked out into the linked worktree, then modified —
12014        // its content is lost on removal, so it is a `dirty` risk (distinct from
12015        // the untracked case).
12016        let main_dir = tempfile::tempdir().unwrap();
12017        let repo = init_repo(main_dir.path());
12018        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12019        repo.set_head("refs/heads/trunk").unwrap();
12020        let wt_parent = tempfile::tempdir().unwrap();
12021        let wt_path = wt_parent.path().join("feature-wt");
12022        add_worktree(&repo, a, &wt_path, "feature");
12023        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12024
12025        let svc = WorktreesService::new();
12026        let report = svc
12027            .handle("close", json!({ "path": wt_path, "remove": true }))
12028            .await
12029            .unwrap();
12030        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12031        assert!(
12032            risks
12033                .iter()
12034                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12035            "expected a dirty risk: {report}"
12036        );
12037    }
12038
12039    #[tokio::test]
12040    async fn close_safety_check_flags_an_in_progress_operation() {
12041        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
12042        // non-Clean (interrupted merge) state — its progress is lost on removal.
12043        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12044        let wt_repo = Repository::open(&wt_path).unwrap();
12045        let head = wt_repo.head().unwrap().target().unwrap();
12046        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12047        assert_ne!(wt_repo.state(), RepositoryState::Clean);
12048
12049        let svc = WorktreesService::new();
12050        let report = svc
12051            .handle("close", json!({ "path": wt_path, "remove": true }))
12052            .await
12053            .unwrap();
12054        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12055        assert!(
12056            risks
12057                .iter()
12058                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12059            "expected an in-progress risk: {report}"
12060        );
12061    }
12062
12063    #[tokio::test]
12064    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12065        // A linked worktree on `feature`, which tracks `origin/feature` and is one
12066        // commit ahead. The unpushed commit is INFO (the branch — and thus the
12067        // commit — survives removal), never a blocking risk.
12068        let main_dir = tempfile::tempdir().unwrap();
12069        let repo = init_repo(main_dir.path());
12070        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12071        repo.set_head("refs/heads/trunk").unwrap();
12072        let a_commit = repo.find_commit(a).unwrap();
12073        repo.branch("feature", &a_commit, false).unwrap();
12074        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12075            .unwrap();
12076        // `feature` advances one commit past `origin/feature`.
12077        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12078        drop(a_commit);
12079        let mut cfg = repo.config().unwrap();
12080        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12081            .unwrap();
12082        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12083            .unwrap();
12084        cfg.set_str("branch.feature.remote", "origin").unwrap();
12085        cfg.set_str("branch.feature.merge", "refs/heads/feature")
12086            .unwrap();
12087        // A worktree on the existing `feature` branch (not created fresh, so it
12088        // keeps the ahead-of-upstream divergence).
12089        let wt_parent = tempfile::tempdir().unwrap();
12090        let wt_path = wt_parent.path().join("feature-wt");
12091        let reference = repo.find_reference("refs/heads/feature").unwrap();
12092        let mut opts = git2::WorktreeAddOptions::new();
12093        opts.reference(Some(&reference));
12094        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12095
12096        let svc = WorktreesService::new();
12097        let report = svc
12098            .handle("close", json!({ "path": wt_path, "remove": true }))
12099            .await
12100            .unwrap();
12101        // Unpushed commits appear as `info`, and the worktree is still cleanly
12102        // removable with no blocking risks.
12103        let info = report.get("info").and_then(Value::as_array).unwrap();
12104        assert!(
12105            info.iter()
12106                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12107            "expected an unpushed info note: {report}"
12108        );
12109        assert!(
12110            report
12111                .get("risks")
12112                .and_then(Value::as_array)
12113                .unwrap()
12114                .is_empty(),
12115            "unpushed commits alone must not block: {report}"
12116        );
12117        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12118    }
12119
12120    #[tokio::test]
12121    async fn close_safety_check_ignores_gitignored_files() {
12122        // With `.gitignore` committed, an ignored artifact is the only worktree
12123        // change — it must not count as untracked (it is regenerable), so the
12124        // worktree stays cleanly removable with no risks.
12125        let main_dir = tempfile::tempdir().unwrap();
12126        let repo = init_repo(main_dir.path());
12127        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12128        repo.set_head("refs/heads/trunk").unwrap();
12129        let wt_parent = tempfile::tempdir().unwrap();
12130        let wt_path = wt_parent.path().join("feature-wt");
12131        add_worktree(&repo, a, &wt_path, "feature");
12132        std::fs::create_dir(wt_path.join("build")).unwrap();
12133        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12134
12135        let svc = WorktreesService::new();
12136        let report = svc
12137            .handle("close", json!({ "path": wt_path, "remove": true }))
12138            .await
12139            .unwrap();
12140        assert!(
12141            report
12142                .get("risks")
12143                .and_then(Value::as_array)
12144                .unwrap()
12145                .is_empty(),
12146            "a gitignored file must not create a risk: {report}"
12147        );
12148        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12149    }
12150
12151    #[tokio::test]
12152    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12153        // The phase-1 check on a path that no longer exists reports it removable
12154        // with no risks (so the idempotent execute proceeds with no dialog).
12155        let svc = WorktreesService::new();
12156        let report = svc
12157            .handle(
12158                "close",
12159                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12160            )
12161            .await
12162            .unwrap();
12163        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12164        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12165        assert!(report
12166            .get("risks")
12167            .and_then(Value::as_array)
12168            .unwrap()
12169            .is_empty());
12170        let info = report.get("info").and_then(Value::as_array).unwrap();
12171        assert!(info
12172            .iter()
12173            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12174    }
12175
12176    #[tokio::test]
12177    async fn close_phase1_errors_on_a_non_git_worktree_path() {
12178        // An existing directory that is *not* a git worktree makes `git_safety`
12179        // fail; the error must propagate (rather than delete an unknown dir),
12180        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
12181        // audit line itself is unit-tested via `log_close_error`.
12182        let dir = tempfile::tempdir().unwrap();
12183        let svc = WorktreesService::new();
12184        let result = svc
12185            .handle("close", json!({ "path": dir.path(), "remove": true }))
12186            .await;
12187        assert!(
12188            result.is_err(),
12189            "a non-git-worktree target must error the safety check, got: {result:?}"
12190        );
12191    }
12192
12193    #[tokio::test]
12194    async fn close_refuses_a_locked_worktree() {
12195        // A locked worktree (git worktree lock) must be refused, not forced past
12196        // (failure mode #6), and left on disk.
12197        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12198        let main_repo = Repository::open(main.path()).unwrap();
12199        main_repo
12200            .find_worktree("feature")
12201            .unwrap()
12202            .lock(Some("under test"))
12203            .unwrap();
12204
12205        let svc = WorktreesService::new();
12206        let err = svc
12207            .handle(
12208                "close",
12209                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12210            )
12211            .await
12212            .unwrap_err();
12213        assert!(
12214            err.to_string().contains("locked"),
12215            "expected a locked error: {err}"
12216        );
12217        assert!(wt_path.exists(), "a locked worktree must not be removed");
12218    }
12219
12220    #[tokio::test]
12221    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12222        // A detached HEAD that still sits on a commit a branch points to loses
12223        // nothing on removal, so it must NOT produce an unreachable-commits risk
12224        // (the false-positive the reachability walk guards against).
12225        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12226        let wt_repo = Repository::open(&wt_path).unwrap();
12227        // The worktree is on `feature`; detach HEAD onto its current tip, which
12228        // the `feature` branch still references.
12229        let tip = wt_repo.head().unwrap().target().unwrap();
12230        wt_repo.set_head_detached(tip).unwrap();
12231        assert!(wt_repo.head_detached().unwrap());
12232
12233        let svc = WorktreesService::new();
12234        let report = svc
12235            .handle("close", json!({ "path": wt_path, "remove": true }))
12236            .await
12237            .unwrap();
12238        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12239        assert!(
12240            !risks
12241                .iter()
12242                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12243            "a detached HEAD reachable from a branch must not be flagged: {report}"
12244        );
12245    }
12246
12247    #[test]
12248    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12249        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12250        let main_repo = Repository::open(main.path()).unwrap();
12251        // The real linked worktree resolves to its registered name.
12252        assert_eq!(
12253            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12254            "feature"
12255        );
12256        // A path that is not one of this repo's worktrees is the defensive
12257        // "not registered" error (the guard behind removal).
12258        let err =
12259            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12260        assert!(
12261            err.to_string().contains("not registered"),
12262            "expected a not-registered error: {err}"
12263        );
12264    }
12265
12266    #[test]
12267    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12268        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
12269        // rather than sinking the whole safety check.
12270        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12271        let repo = Repository::open(&wt_path).unwrap();
12272        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12273        // Confirm the corruption actually breaks status enumeration, so the
12274        // count is exercising the error-degradation branch (not an empty repo).
12275        assert!(
12276            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12277            "a corrupt index should make statuses() fail"
12278        );
12279        assert_eq!(count_dirty_untracked(&repo), (0, 0));
12280    }
12281}