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::{retry_on_etxtbsy, shim_lock, write_exec_script};
5127    use std::sync::MutexGuard;
5128
5129    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5130        json!({
5131            "key": key,
5132            "folders": [folder],
5133            "repo": repo,
5134            "title": format!("{key}-title"),
5135            "pid": 1234,
5136        })
5137    }
5138
5139    /// Pulls the `windows` array out of a `list`/`status` payload.
5140    fn windows_of(payload: &Value) -> &Vec<Value> {
5141        payload
5142            .get("windows")
5143            .and_then(Value::as_array)
5144            .expect("windows array")
5145    }
5146
5147    #[tokio::test]
5148    async fn name_and_unknown_op() {
5149        let svc = WorktreesService::new();
5150        assert_eq!(svc.name(), "worktrees");
5151        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5152    }
5153
5154    #[tokio::test]
5155    async fn handle_routes_ops_and_shapes_payloads() {
5156        let svc = WorktreesService::new();
5157        // Empty to start.
5158        let payload = svc.handle("list", Value::Null).await.unwrap();
5159        assert_eq!(payload, json!({ "windows": [] }));
5160
5161        // register → { ok: true }, then it shows up in list.
5162        let reply = svc
5163            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5164            .await
5165            .unwrap();
5166        assert_eq!(reply, json!({ "ok": true }));
5167        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5168        assert_eq!(windows.len(), 1);
5169        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5170        assert!(windows[0].get("last_seen").is_some());
5171
5172        // heartbeat known/unknown.
5173        let known = svc
5174            .handle("heartbeat", json!({ "key": "w1" }))
5175            .await
5176            .unwrap();
5177        assert_eq!(known, json!({ "known": true }));
5178        let unknown = svc
5179            .handle("heartbeat", json!({ "key": "nope" }))
5180            .await
5181            .unwrap();
5182        assert_eq!(unknown, json!({ "known": false }));
5183
5184        // reload signals a live window and reports one it does not know.
5185        let reloaded = svc
5186            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5187            .await
5188            .unwrap();
5189        assert_eq!(
5190            reloaded,
5191            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5192        );
5193        assert!(svc.registry.take_reload_pending("w1"));
5194
5195        // unregister removes, then repeats as a no-op success.
5196        let gone = svc
5197            .handle("unregister", json!({ "key": "w1" }))
5198            .await
5199            .unwrap();
5200        assert_eq!(gone, json!({ "removed": true }));
5201        let again = svc
5202            .handle("unregister", json!({ "key": "w1" }))
5203            .await
5204            .unwrap();
5205        assert_eq!(again, json!({ "removed": false }));
5206    }
5207
5208    // --- Reposition op (#1407) ------------------------------------------------
5209
5210    /// A window backend over an in-memory window table that **actually applies**
5211    /// writes, so the adapter's own responsibilities — key resolution, the undo
5212    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
5213    /// no Accessibility grant. The planner/matcher itself is covered by
5214    /// `geometry`'s own tests.
5215    ///
5216    /// Applying the writes is what makes a reposition-then-undo round trip mean
5217    /// anything: against a fixed table the restore would find every window already
5218    /// in its wanted position and correctly report `unchanged`.
5219    #[derive(Clone)]
5220    struct StubBackend {
5221        trusted: bool,
5222        /// One application's windows. `Arc` so every clone the factory hands out —
5223        /// and every op in a test — shares the same mutating table.
5224        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5225        /// Every frame written, in order.
5226        writes: Arc<Mutex<Vec<geometry::Frame>>>,
5227    }
5228
5229    impl StubBackend {
5230        /// One application (pid 900) with two default-format VS Code windows, and
5231        /// two ext-host pids (11, 12) mapping onto it.
5232        fn new(trusted: bool) -> Self {
5233            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5234                title: title.to_string(),
5235                frame: geometry::Frame {
5236                    x,
5237                    y: 0.0,
5238                    width,
5239                    height: 600.0,
5240                },
5241                minimized: false,
5242                fullscreen: false,
5243                standard: true,
5244                focused: false,
5245            };
5246            Self {
5247                trusted,
5248                windows: Arc::new(Mutex::new(vec![
5249                    window("plan.md — ref-tree", 0.0, 800.0),
5250                    window("main.rs — other-tree", 900.0, 500.0),
5251                ])),
5252                writes: Arc::new(Mutex::new(Vec::new())),
5253            }
5254        }
5255
5256        fn writes(&self) -> Vec<geometry::Frame> {
5257            self.writes
5258                .lock()
5259                .unwrap_or_else(PoisonError::into_inner)
5260                .clone()
5261        }
5262
5263        /// The frame a window currently occupies, after any applied writes.
5264        fn frame_of(&self, index: usize) -> geometry::Frame {
5265            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5266        }
5267
5268        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
5269        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5270            let clone = self.clone();
5271            move || clone
5272        }
5273    }
5274
5275    impl geometry::WindowBackend for StubBackend {
5276        fn trusted(&self) -> bool {
5277            self.trusted
5278        }
5279
5280        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5281            pids.iter()
5282                .filter(|p| **p == 11 || **p == 12)
5283                .map(|p| (*p, 900))
5284                .collect()
5285        }
5286
5287        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5288            if app_pid != 900 {
5289                return Ok(Vec::new());
5290            }
5291            Ok(self
5292                .windows
5293                .lock()
5294                .unwrap_or_else(PoisonError::into_inner)
5295                .clone())
5296        }
5297
5298        fn set_frame(
5299            &self,
5300            id: geometry::WindowId,
5301            frame: geometry::Frame,
5302        ) -> Result<geometry::Frame, String> {
5303            self.writes
5304                .lock()
5305                .unwrap_or_else(PoisonError::into_inner)
5306                .push(frame);
5307            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5308            let window = windows
5309                .get_mut(id.index)
5310                .ok_or_else(|| format!("no window at index {}", id.index))?;
5311            window.frame = frame;
5312            Ok(frame)
5313        }
5314    }
5315
5316    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
5317    /// one the stub backend can resolve to an OS window.
5318    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5319        svc.registry.register(
5320            serde_json::from_value(json!({
5321                "key": key,
5322                "folders": [format!("/tmp/{key}")],
5323                "title": title,
5324                "pid": pid,
5325            }))
5326            .expect("valid register payload"),
5327        );
5328    }
5329
5330    #[tokio::test]
5331    async fn reposition_requires_a_resolvable_reference() {
5332        let svc = WorktreesService::new();
5333        // A missing, blank, or unknown reference key is a hard error: with no
5334        // reference there is no geometry to copy, so the request is meaningless.
5335        assert!(svc.handle("reposition", json!({})).await.is_err());
5336        assert!(svc
5337            .handle("reposition", json!({ "reference_key": "  " }))
5338            .await
5339            .is_err());
5340        assert!(svc
5341            .handle("reposition", json!({ "reference_key": "ghost" }))
5342            .await
5343            .is_err());
5344    }
5345
5346    #[tokio::test]
5347    async fn reposition_moves_targets_and_records_an_undo() {
5348        let svc = WorktreesService::new();
5349        register_window(&svc, "ref", "ref-tree", 11);
5350        register_window(&svc, "other", "other-tree", 12);
5351        let backend = StubBackend::new(true);
5352
5353        let reply = svc
5354            .reposition_with(
5355                serde_json::from_value(json!({
5356                    "reference_key": "ref",
5357                    "target_keys": ["other"],
5358                }))
5359                .unwrap(),
5360                backend.factory(),
5361            )
5362            .await
5363            .unwrap();
5364
5365        assert_eq!(reply["trusted"], json!(true));
5366        assert_eq!(reply["moved"], json!(1));
5367        assert_eq!(reply["skipped"], json!(0));
5368        assert_eq!(reply["undoable"], json!(true));
5369        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5370        assert_eq!(reply["results"][0]["key"], json!("other"));
5371        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5372        // Written the reference's own frame, read from the stub's window table.
5373        assert_eq!(backend.writes().len(), 1);
5374        assert_eq!(
5375            backend.writes()[0],
5376            backend.frame_of(0),
5377            "wrote the reference window's own frame"
5378        );
5379        assert_eq!(
5380            backend.frame_of(1),
5381            backend.frame_of(0),
5382            "the target now occupies the reference's frame"
5383        );
5384
5385        // Undo puts it back where it was — the same backend, so it sees the window
5386        // where the move left it — and consumes the record, so a second undo has
5387        // nothing left to replay onto a layout the user may have since redone.
5388        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5389        assert_eq!(undone["moved"], json!(1));
5390        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5391        assert!(undone.get("reference").is_none(), "undo has no reference");
5392        assert_eq!(
5393            backend.frame_of(1),
5394            geometry::Frame {
5395                x: 900.0,
5396                y: 0.0,
5397                width: 500.0,
5398                height: 600.0,
5399            },
5400            "restored to exactly the pre-move frame"
5401        );
5402
5403        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5404        assert_eq!(
5405            again,
5406            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5407        );
5408    }
5409
5410    #[tokio::test]
5411    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5412        let svc = WorktreesService::new();
5413        register_window(&svc, "ref", "ref-tree", 11);
5414        register_window(&svc, "other", "other-tree", 12);
5415        let backend = StubBackend::new(true);
5416
5417        let reply = svc
5418            .reposition_with(
5419                serde_json::from_value(json!({
5420                    "reference_key": "ref",
5421                    "target_keys": ["other"],
5422                    "check": true,
5423                }))
5424                .unwrap(),
5425                backend.factory(),
5426            )
5427            .await
5428            .unwrap();
5429
5430        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5431        assert!(
5432            reply.get("undoable").is_none(),
5433            "a dry run leaves nothing to undo"
5434        );
5435        assert!(
5436            backend.writes().is_empty(),
5437            "a dry run must not touch a window"
5438        );
5439    }
5440
5441    #[tokio::test]
5442    async fn reposition_reports_a_missing_permission_as_data() {
5443        let svc = WorktreesService::new();
5444        register_window(&svc, "ref", "ref-tree", 11);
5445        register_window(&svc, "other", "other-tree", 12);
5446        let backend = StubBackend::new(false);
5447
5448        let reply = svc
5449            .reposition_with(
5450                serde_json::from_value(json!({
5451                    "reference_key": "ref",
5452                    "target_keys": ["other"],
5453                }))
5454                .unwrap(),
5455                backend.factory(),
5456            )
5457            .await
5458            .unwrap();
5459
5460        // Not an error: the client branches on `trusted` to offer the user a link
5461        // to the Accessibility settings pane.
5462        assert_eq!(reply["trusted"], json!(false));
5463        assert_eq!(reply["moved"], json!(0));
5464        assert!(backend.writes().is_empty());
5465    }
5466
5467    #[tokio::test]
5468    async fn a_stale_target_key_is_skipped_not_fatal() {
5469        let svc = WorktreesService::new();
5470        register_window(&svc, "ref", "ref-tree", 11);
5471        register_window(&svc, "other", "other-tree", 12);
5472        let backend = StubBackend::new(true);
5473
5474        let reply = svc
5475            .reposition_with(
5476                serde_json::from_value(json!({
5477                    "reference_key": "ref",
5478                    // A window that closed since the tree row was rendered, the
5479                    // reference itself, and a real target.
5480                    "target_keys": ["closed-since", "ref", "other"],
5481                }))
5482                .unwrap(),
5483                backend.factory(),
5484            )
5485            .await
5486            .unwrap();
5487
5488        let outcomes: Vec<&str> = reply["results"]
5489            .as_array()
5490            .unwrap()
5491            .iter()
5492            .map(|r| r["outcome"].as_str().unwrap())
5493            .collect();
5494        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5495        assert_eq!(reply["moved"], json!(1));
5496        assert_eq!(reply["skipped"], json!(2));
5497    }
5498
5499    #[tokio::test]
5500    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5501        let svc = WorktreesService::new();
5502        // Two windows share a root name, so the *reference* cannot be resolved and
5503        // the whole batch is refused before any target is attempted.
5504        register_window(&svc, "ref", "twin", 11);
5505        register_window(&svc, "other", "other-tree", 12);
5506        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5507        // `mut` binding — and the same shared table backs the factory's clone.
5508        let backend = StubBackend::new(true);
5509        {
5510            let mut windows = backend
5511                .windows
5512                .lock()
5513                .unwrap_or_else(PoisonError::into_inner);
5514            windows[0].title = "a.rs — twin".to_string();
5515            windows[1].title = "b.rs — twin".to_string();
5516        }
5517
5518        let reply = svc
5519            .reposition_with(
5520                serde_json::from_value(json!({
5521                    "reference_key": "ref",
5522                    "target_keys": ["other"],
5523                }))
5524                .unwrap(),
5525                backend.factory(),
5526            )
5527            .await
5528            .unwrap();
5529
5530        assert_eq!(reply["trusted"], json!(true));
5531        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5532        assert!(
5533            reply["blocked"]["detail"]
5534                .as_str()
5535                .is_some_and(|d| d.contains("twin")),
5536            "the reason should name the ambiguous title: {reply}"
5537        );
5538        assert_eq!(reply["results"], json!([]), "no target is attempted");
5539        assert!(reply.get("undoable").is_none());
5540        assert!(backend.writes().is_empty());
5541
5542        // And nothing was recorded, so a following undo has nothing to replay.
5543        let undone = svc
5544            .reposition_undo_with(StubBackend::new(true).factory())
5545            .await
5546            .unwrap();
5547        assert_eq!(undone["moved"], json!(0));
5548    }
5549
5550    #[tokio::test]
5551    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5552        let svc = WorktreesService::new();
5553        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5554        assert_eq!(reply["moved"], json!(0));
5555        assert_eq!(reply["results"], json!([]));
5556    }
5557
5558    #[test]
5559    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5560        let empty = geometry::RepositionReport {
5561            trusted: true,
5562            blocked: None,
5563            reference: None,
5564            results: Vec::new(),
5565            undo: Vec::new(),
5566        };
5567        assert_eq!(outcome_kinds(&empty), "-");
5568    }
5569
5570    #[tokio::test]
5571    async fn handle_rejects_missing_or_empty_key() {
5572        let svc = WorktreesService::new();
5573        // register validates a present, non-blank key.
5574        assert!(svc.handle("register", json!({})).await.is_err());
5575        assert!(svc
5576            .handle("register", json!({ "key": "  " }))
5577            .await
5578            .is_err());
5579        // heartbeat/unregister require the key via `require_str`.
5580        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5581        assert!(svc.handle("unregister", json!({})).await.is_err());
5582    }
5583
5584    #[test]
5585    fn display_name_prefers_repo_then_folder_basename() {
5586        let base = WindowEntry {
5587            key: "k".to_string(),
5588            folders: vec![PathBuf::from("/home/me/project")],
5589            repo: Some("my-repo".to_string()),
5590            title: None,
5591            pid: None,
5592            last_seen: Utc::now(),
5593        };
5594        assert_eq!(display_name(&base), "my-repo");
5595
5596        let no_repo = WindowEntry {
5597            repo: None,
5598            ..base.clone()
5599        };
5600        assert_eq!(display_name(&no_repo), "project");
5601
5602        let nothing = WindowEntry {
5603            repo: None,
5604            folders: vec![],
5605            ..base.clone()
5606        };
5607        assert_eq!(display_name(&nothing), "(no folder)");
5608
5609        // A folder with no basename (the filesystem root) falls back to its
5610        // displayed path rather than panicking or yielding an empty name.
5611        let rootish = WindowEntry {
5612            repo: None,
5613            folders: vec![PathBuf::from("/")],
5614            ..base
5615        };
5616        assert_eq!(display_name(&rootish), "/");
5617    }
5618
5619    #[test]
5620    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5621        let now = Utc::now();
5622        let entries = vec![
5623            // A folderless window has nothing to focus, so it stays a plain
5624            // Label; a title equal to the name collapses to just the name. It
5625            // leads the list so the focus-action lookup below is exercised
5626            // against a leading non-Action item it has to skip.
5627            WindowEntry {
5628                key: "k2".to_string(),
5629                folders: vec![],
5630                repo: Some("solo".to_string()),
5631                title: Some("solo".to_string()),
5632                pid: None,
5633                last_seen: now,
5634            },
5635            // A folder-bearing, non-repo window: one clickable Action whose label
5636            // is the stats line ("name · title", since /tmp is not a git repo).
5637            WindowEntry {
5638                key: "k1".to_string(),
5639                folders: vec![PathBuf::from("/tmp/a")],
5640                repo: Some("repo".to_string()),
5641                title: Some("a branch".to_string()),
5642                pid: None,
5643                last_seen: now,
5644            },
5645        ];
5646        let items = window_menu_items(&entries);
5647        // Exactly one item per window — no duplicate label, no separator.
5648        assert_eq!(items.len(), 2);
5649        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5650
5651        // The folder-bearing window is a single clickable action carrying the
5652        // stats label (the old label + Focus action, merged).
5653        let action = items
5654            .iter()
5655            .find_map(|i| match i {
5656                MenuItem::Action(a) => Some(a),
5657                _ => None,
5658            })
5659            .expect("a focus action");
5660        assert_eq!(action.id, "focus:k1");
5661        assert_eq!(action.label, "repo · a branch");
5662
5663        // The folderless window is a non-clickable label (not "solo · solo").
5664        let labels: Vec<&str> = items
5665            .iter()
5666            .filter_map(|i| match i {
5667                MenuItem::Label(t) => Some(t.as_str()),
5668                _ => None,
5669            })
5670            .collect();
5671        assert_eq!(labels, vec!["solo"]);
5672    }
5673
5674    #[tokio::test]
5675    async fn menu_and_status_shapes() {
5676        let svc = WorktreesService::new();
5677        // Empty.
5678        let menu = svc.menu();
5679        assert_eq!(menu.title, "Worktrees");
5680        assert!(matches!(
5681            menu.items.first(),
5682            Some(MenuItem::Label(text)) if text == "No open windows"
5683        ));
5684        let status = svc.status().await;
5685        assert_eq!(status.name, "worktrees");
5686        assert!(status.healthy);
5687        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5688
5689        // Two folder-bearing windows in the same repo, plus one folderless
5690        // window that shares the repo but has nothing for `code` to open.
5691        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5692            .await
5693            .unwrap();
5694        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5695            .await
5696            .unwrap();
5697        svc.handle(
5698            "register",
5699            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5700        )
5701        .await
5702        .unwrap();
5703        let status = svc.status().await;
5704        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5705
5706        let menu = svc.menu();
5707        // One line per window — no separator, no duplicate label.
5708        assert_eq!(menu.items.len(), 3);
5709        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5710        let action_ids: Vec<&str> = menu
5711            .items
5712            .iter()
5713            .filter_map(|i| match i {
5714                MenuItem::Action(a) => Some(a.id.as_str()),
5715                _ => None,
5716            })
5717            .collect();
5718        // The two folder-bearing windows are clickable; the folderless one is a
5719        // plain Label, so it never yields a focus action.
5720        assert!(action_ids.contains(&"focus:w1"));
5721        assert!(action_ids.contains(&"focus:w2"));
5722        assert!(!action_ids.contains(&"focus:w3"));
5723    }
5724
5725    #[test]
5726    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5727        // With no tokio runtime, the background task is never spawned, so the
5728        // bare service keeps computing `menu()` inline (what the tests rely on).
5729        let svc = WorktreesService::new();
5730        svc.start_menu_refresh();
5731        assert!(svc.refresh.lock().unwrap().is_none());
5732    }
5733
5734    #[tokio::test]
5735    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5736        let svc = WorktreesService::new();
5737        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5738            .await
5739            .unwrap();
5740        // Before the task runs, `menu()` computes inline from an empty cache.
5741        assert!(svc.menu_cache.lock().unwrap().is_none());
5742
5743        svc.start_menu_refresh();
5744        // Idempotent: a second call does not start a second task.
5745        svc.start_menu_refresh();
5746
5747        // The task fills the cache off the main thread; poll briefly for it.
5748        let mut filled = false;
5749        for _ in 0..100 {
5750            if svc.menu_cache.lock().unwrap().is_some() {
5751                filled = true;
5752                break;
5753            }
5754            tokio::time::sleep(Duration::from_millis(10)).await;
5755        }
5756        assert!(filled, "background refresh should populate the menu cache");
5757
5758        // `menu()` now serves the cache: one clickable line for the window.
5759        let menu = svc.menu();
5760        assert_eq!(menu.title, "Worktrees");
5761        assert!(menu
5762            .items
5763            .iter()
5764            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5765
5766        // Shutdown cancels and joins the task, clearing the handle.
5767        svc.shutdown().await;
5768        assert!(svc.refresh.lock().unwrap().is_none());
5769    }
5770
5771    #[tokio::test]
5772    async fn default_constructs_an_empty_service() {
5773        let svc = WorktreesService::default();
5774        let payload = svc.handle("list", Value::Null).await.unwrap();
5775        assert_eq!(payload, json!({ "windows": [] }));
5776    }
5777
5778    // --- Push subscription (#1267) -----------------------------------------
5779
5780    #[tokio::test]
5781    async fn subscribe_streams_only_for_the_subscribe_op() {
5782        let svc = WorktreesService::new();
5783        // The one streaming op yields a stream; every other op (including the
5784        // request/reply worktrees ops) declines, so the server dispatches them
5785        // normally.
5786        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5787        assert!(svc.subscribe("list", &Value::Null).is_none());
5788        assert!(svc.subscribe("register", &Value::Null).is_none());
5789        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5790    }
5791
5792    #[tokio::test]
5793    async fn subscribe_snapshot_matches_the_tree_op() {
5794        let dir = tempfile::tempdir().unwrap();
5795        let repo = init_repo(dir.path());
5796        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5797        repo.set_head("refs/heads/main").unwrap();
5798
5799        let svc = WorktreesService::new();
5800        let stream = svc
5801            .subscribe("subscribe", &Value::Null)
5802            .expect("subscribe stream");
5803        // No windows yet → no repos derived; the toggle rides along at its
5804        // default (show all).
5805        assert_eq!(
5806            stream.snapshot().await,
5807            json!({ "repos": [], "show_closed": true })
5808        );
5809
5810        // A window opens on the repo → the snapshot carries it, byte-identical to
5811        // what the `tree` op returns for the same registry state.
5812        svc.handle(
5813            "register",
5814            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5815        )
5816        .await
5817        .unwrap();
5818        let snap = stream.snapshot().await;
5819        let tree = svc.handle("tree", Value::Null).await.unwrap();
5820        assert_eq!(snap, tree);
5821        let repos = snap["repos"].as_array().expect("repos array");
5822        assert_eq!(repos.len(), 1);
5823        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5824    }
5825
5826    #[tokio::test]
5827    async fn subscribe_changed_wakes_on_register() {
5828        let svc = WorktreesService::new();
5829        let mut stream = svc
5830            .subscribe("subscribe", &Value::Null)
5831            .expect("subscribe stream");
5832        // Idle: `changed()` must not resolve without a registry change.
5833        tokio::select! {
5834            () = stream.changed() => panic!("changed resolved with no registry change"),
5835            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5836        }
5837        // A register bumps the change-notify → `changed()` resolves promptly.
5838        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5839            .await
5840            .unwrap();
5841        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5842            .await
5843            .expect("changed should resolve after a register");
5844    }
5845
5846    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5847
5848    #[tokio::test]
5849    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5850        let reg = Arc::new(WorktreesRegistry::new());
5851        // A long TTL so only the generation gate is exercised here.
5852        let cache = TreeSnapshotCache::with_ttl(
5853            reg,
5854            Arc::new(PrStatusCache::new()),
5855            Duration::from_secs(60),
5856        );
5857        // The first read builds once.
5858        let first = cache.snapshot().await;
5859        assert_eq!(cache.compute_count(), 1);
5860        // Further reads with no registry change and within the TTL reuse the
5861        // cached value — no extra build, byte-identical result.
5862        let second = cache.snapshot().await;
5863        assert_eq!(
5864            cache.compute_count(),
5865            1,
5866            "an unchanged read must not rebuild"
5867        );
5868        assert_eq!(first, second);
5869    }
5870
5871    #[tokio::test]
5872    async fn tree_cache_single_flights_a_read_burst() {
5873        let reg = Arc::new(WorktreesRegistry::new());
5874        let cache = Arc::new(TreeSnapshotCache::with_ttl(
5875            reg,
5876            Arc::new(PrStatusCache::new()),
5877            Duration::from_secs(60),
5878        ));
5879        // A burst of concurrent readers — as N subscriber streams would wake
5880        // together on a change/tick — collapses to exactly one build; the rest
5881        // read the shared result (the acceptance criterion).
5882        let mut handles = Vec::new();
5883        for _ in 0..16 {
5884            let cache = cache.clone();
5885            handles.push(tokio::spawn(async move { cache.snapshot().await }));
5886        }
5887        let mut results = Vec::new();
5888        for handle in handles {
5889            results.push(handle.await.unwrap());
5890        }
5891        assert_eq!(
5892            cache.compute_count(),
5893            1,
5894            "a concurrent read burst must build the tree once"
5895        );
5896        assert!(
5897            results.windows(2).all(|w| w[0] == w[1]),
5898            "every reader must observe the identical snapshot"
5899        );
5900    }
5901
5902    #[tokio::test]
5903    async fn tree_cache_rebuilds_on_registry_change() {
5904        let reg = Arc::new(WorktreesRegistry::new());
5905        let cache = TreeSnapshotCache::with_ttl(
5906            reg.clone(),
5907            Arc::new(PrStatusCache::new()),
5908            Duration::from_secs(60),
5909        );
5910        cache.snapshot().await;
5911        assert_eq!(cache.compute_count(), 1);
5912        // A registry change bumps the generation, so the next read rebuilds even
5913        // though the (long) TTL has not expired — subscribers never see a stale
5914        // visible set.
5915        assert!(reg.set_show_closed(false));
5916        cache.snapshot().await;
5917        assert_eq!(
5918            cache.compute_count(),
5919            2,
5920            "a generation bump must force a rebuild"
5921        );
5922    }
5923
5924    #[tokio::test]
5925    async fn tree_cache_rebuilds_after_ttl_expiry() {
5926        let reg = Arc::new(WorktreesRegistry::new());
5927        // A zero TTL: every read is already past it, so a pure on-disk git change
5928        // still surfaces on the next tick with no registry bump needed.
5929        let cache =
5930            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
5931        cache.snapshot().await;
5932        cache.snapshot().await;
5933        assert_eq!(
5934            cache.compute_count(),
5935            2,
5936            "an expired TTL must force a rebuild each read"
5937        );
5938    }
5939
5940    #[tokio::test]
5941    async fn subscribe_streams_share_one_build_per_generation() {
5942        let svc = WorktreesService::new();
5943        let s1 = svc
5944            .subscribe("subscribe", &Value::Null)
5945            .expect("subscribe stream");
5946        let s2 = svc
5947            .subscribe("subscribe", &Value::Null)
5948            .expect("subscribe stream");
5949        // Two windows' streams sampling the same registry state build the tree
5950        // once, not once per stream (#1303) — they share the service's cache.
5951        let a = s1.snapshot().await;
5952        let b = s2.snapshot().await;
5953        assert_eq!(a, b);
5954        assert_eq!(
5955            svc.tree_cache.compute_count(),
5956            1,
5957            "N streams on one generation must share a single build"
5958        );
5959    }
5960
5961    // --- Show/hide-closed toggle (#1301) -----------------------------------
5962
5963    #[tokio::test]
5964    async fn set_show_closed_toggles_the_snapshot_field() {
5965        let svc = WorktreesService::new();
5966        // The snapshot carries the toggle; it defaults to show-all.
5967        assert_eq!(
5968            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5969            json!(true)
5970        );
5971        // Setting it flips the field the next snapshot reports.
5972        let reply = svc
5973            .handle("set-show-closed", json!({ "show_closed": false }))
5974            .await
5975            .unwrap();
5976        assert_eq!(reply, json!({ "ok": true }));
5977        assert_eq!(
5978            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
5979            json!(false)
5980        );
5981    }
5982
5983    #[tokio::test]
5984    async fn set_show_closed_rejects_a_non_boolean_payload() {
5985        let svc = WorktreesService::new();
5986        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
5987        assert!(svc
5988            .handle("set-show-closed", json!({ "show_closed": "yes" }))
5989            .await
5990            .is_err());
5991    }
5992
5993    #[tokio::test]
5994    async fn set_show_closed_wakes_the_subscription() {
5995        let svc = WorktreesService::new();
5996        let mut stream = svc
5997            .subscribe("subscribe", &Value::Null)
5998            .expect("subscribe stream");
5999        // A real flip bumps the change-notify → `changed()` resolves promptly.
6000        svc.handle("set-show-closed", json!({ "show_closed": false }))
6001            .await
6002            .unwrap();
6003        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6004            .await
6005            .expect("changed should resolve after a toggle flip");
6006        // The pushed snapshot now reflects the new toggle.
6007        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6008    }
6009
6010    #[tokio::test]
6011    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6012        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
6013        // drops it (skip-if-false), so the extension colours the icon off the flag.
6014        let dir = tempfile::tempdir().unwrap();
6015        github_repo(dir.path());
6016        let svc = WorktreesService::new();
6017        svc.handle(
6018            "register",
6019            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6020        )
6021        .await
6022        .unwrap();
6023
6024        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6025        assert!(
6026            repo.get("polling_enabled").is_none(),
6027            "default off omits the flag: {repo:?}"
6028        );
6029
6030        let reply = svc
6031            .handle(
6032                "set-polling",
6033                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6034            )
6035            .await
6036            .unwrap();
6037        assert_eq!(reply, json!({ "ok": true }));
6038        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6039        assert_eq!(repo["polling_enabled"], json!(true));
6040
6041        svc.handle(
6042            "set-polling",
6043            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6044        )
6045        .await
6046        .unwrap();
6047        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6048        assert!(repo.get("polling_enabled").is_none());
6049    }
6050
6051    #[tokio::test]
6052    async fn set_polling_rejects_missing_or_empty_fields() {
6053        let svc = WorktreesService::new();
6054        // Missing `enabled`.
6055        assert!(svc
6056            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6057            .await
6058            .is_err());
6059        // Missing `owner`/`name`.
6060        assert!(svc
6061            .handle("set-polling", json!({ "enabled": true }))
6062            .await
6063            .is_err());
6064        // Blank `owner`/`name`.
6065        assert!(svc
6066            .handle(
6067                "set-polling",
6068                json!({ "owner": " ", "name": "n", "enabled": true })
6069            )
6070            .await
6071            .is_err());
6072    }
6073
6074    #[tokio::test]
6075    async fn set_polling_wakes_the_subscription() {
6076        let dir = tempfile::tempdir().unwrap();
6077        github_repo(dir.path());
6078        let svc = WorktreesService::new();
6079        svc.handle(
6080            "register",
6081            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6082        )
6083        .await
6084        .unwrap();
6085        let mut stream = svc
6086            .subscribe("subscribe", &Value::Null)
6087            .expect("subscribe stream");
6088        svc.handle(
6089            "set-polling",
6090            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6091        )
6092        .await
6093        .unwrap();
6094        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6095            .await
6096            .expect("changed should resolve after enabling a repo");
6097        let repo = repos_of(&stream.snapshot().await)[0].clone();
6098        assert_eq!(repo["polling_enabled"], json!(true));
6099    }
6100
6101    #[tokio::test]
6102    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6103        // The "drop existing badges immediately" requirement (#1376), done
6104        // daemon-side: the fold skips a not-polled repo, so a disable strips the
6105        // badge on the very next snapshot rather than waiting for a poll.
6106        let dir = tempfile::tempdir().unwrap();
6107        let repo = github_repo(dir.path());
6108        let head = repo.head().unwrap().target().unwrap().to_string();
6109        let svc = WorktreesService::new();
6110        svc.handle(
6111            "register",
6112            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6113        )
6114        .await
6115        .unwrap();
6116        svc.registry.set_polling("rust-works", "omni-dev", true);
6117
6118        let mut badges = HashMap::new();
6119        badges.insert(
6120            PrTarget {
6121                owner: "rust-works".into(),
6122                name: "omni-dev".into(),
6123                branch: "main".into(),
6124            },
6125            pr(pending_badge(7, &head)),
6126        );
6127        svc.pr_cache.replace(badges);
6128
6129        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6130        assert_eq!(wt["pr"]["number"], json!(7));
6131
6132        svc.handle(
6133            "set-polling",
6134            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6135        )
6136        .await
6137        .unwrap();
6138        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6139        assert!(
6140            wt.get("pr").is_none(),
6141            "a disabled repo must carry no badge: {wt:?}"
6142        );
6143    }
6144
6145    #[tokio::test]
6146    async fn an_expired_lease_drops_the_flag_and_badges() {
6147        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
6148        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
6149        // poller would no longer watch it — all reaped on read, no timer.
6150        let dir = tempfile::tempdir().unwrap();
6151        let repo = github_repo(dir.path());
6152        let head = repo.head().unwrap().target().unwrap().to_string();
6153        let svc = WorktreesService::new();
6154        svc.handle(
6155            "register",
6156            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6157        )
6158        .await
6159        .unwrap();
6160        svc.registry.set_polling("rust-works", "omni-dev", true);
6161        let mut badges = HashMap::new();
6162        badges.insert(
6163            PrTarget {
6164                owner: "rust-works".into(),
6165                name: "omni-dev".into(),
6166                branch: "main".into(),
6167            },
6168            pr(pending_badge(7, &head)),
6169        );
6170        svc.pr_cache.replace(badges);
6171
6172        // Leased: flag stamped, badge folded, and the poller would watch it.
6173        let snap = svc.handle("tree", Value::Null).await.unwrap();
6174        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6175        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6176        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6177
6178        // Force the lease into the past — as 15 minutes elapsing would.
6179        svc.registry.set_polling_expiry(
6180            "rust-works",
6181            "omni-dev",
6182            Utc::now() - chrono::Duration::minutes(1),
6183        );
6184
6185        let snap = svc.handle("tree", Value::Null).await.unwrap();
6186        let repo0 = &repos_of(&snap)[0];
6187        assert!(
6188            repo0.get("polling_enabled").is_none(),
6189            "expired lease drops the flag: {repo0:?}"
6190        );
6191        assert!(
6192            repo0["worktrees"][0].get("pr").is_none(),
6193            "expired lease drops the badge"
6194        );
6195        assert!(
6196            pr_targets_from_snapshot(&snap).is_empty(),
6197            "the poller no longer watches an expired repo"
6198        );
6199    }
6200
6201    #[tokio::test]
6202    async fn polling_prefs_persist_across_reloads_with_0600() {
6203        // The enable set survives a daemon restart (#1376): a change writes the
6204        // `0600` file, and a fresh service seeded from it comes up enabled.
6205        let dir = tempfile::tempdir().unwrap();
6206        let prefs = dir.path().join("worktrees-polling.json");
6207
6208        let svc = WorktreesService::new();
6209        svc.load_polling_prefs(prefs.clone());
6210        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6211        svc.handle(
6212            "set-polling",
6213            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6214        )
6215        .await
6216        .unwrap();
6217        assert!(prefs.exists());
6218        #[cfg(unix)]
6219        {
6220            use std::os::unix::fs::PermissionsExt;
6221            assert_eq!(
6222                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6223                0o600
6224            );
6225        }
6226
6227        // A new service reloads the enabled set from that file.
6228        let svc2 = WorktreesService::new();
6229        svc2.load_polling_prefs(prefs.clone());
6230        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6231
6232        // Disabling rewrites the file, so the next reload has nothing enabled.
6233        svc2.handle(
6234            "set-polling",
6235            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6236        )
6237        .await
6238        .unwrap();
6239        let svc3 = WorktreesService::new();
6240        svc3.load_polling_prefs(prefs);
6241        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6242    }
6243
6244    #[test]
6245    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6246        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
6247        // path is logged and treated as "nothing enabled" rather than wedging the
6248        // service. A missing file is already the first-run default (covered by the
6249        // persistence round-trip above); this exercises the two error branches.
6250        let dir = tempfile::tempdir().unwrap();
6251
6252        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
6253        let corrupt = dir.path().join("worktrees-polling.json");
6254        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6255        let svc = WorktreesService::new();
6256        svc.load_polling_prefs(corrupt);
6257        assert!(svc.registry.enabled_polling_repos().is_empty());
6258
6259        // A directory at the path — the (non-NotFound) read error is swallowed too.
6260        let as_dir = dir.path().join("is-a-directory");
6261        std::fs::create_dir(&as_dir).unwrap();
6262        let svc2 = WorktreesService::new();
6263        svc2.load_polling_prefs(as_dir);
6264        assert!(svc2.registry.enabled_polling_repos().is_empty());
6265    }
6266
6267    #[tokio::test]
6268    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6269        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
6270        // the user has not enabled polling for it — so the poller spawns no `gh`.
6271        let dir = tempfile::tempdir().unwrap();
6272        github_repo(dir.path());
6273        let bin_dir = tempfile::tempdir().unwrap();
6274        let marker = bin_dir.path().join("spawned");
6275        let fake = bin_dir.path().join("fake-gh");
6276        std::fs::write(
6277            &fake,
6278            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6279        )
6280        .unwrap();
6281        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6282        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6283        std::fs::set_permissions(&fake, perms).unwrap();
6284
6285        let svc = WorktreesService::new();
6286        svc.handle(
6287            "register",
6288            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6289        )
6290        .await
6291        .unwrap();
6292        // Deliberately NOT enabling polling for the repo.
6293        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6294        tokio::time::sleep(Duration::from_millis(200)).await;
6295        svc.shutdown().await;
6296        assert!(
6297            !marker.exists(),
6298            "a registered-but-not-enabled repo must drive zero gh"
6299        );
6300    }
6301
6302    #[tokio::test]
6303    async fn menu_action_rejects_unknown_and_missing_window() {
6304        let svc = WorktreesService::new();
6305        assert!(svc.menu_action("bogus").await.is_err());
6306        // A focus for a key with no registration errors rather than spawning.
6307        assert!(svc.menu_action("focus:nope").await.is_err());
6308        svc.shutdown().await;
6309    }
6310
6311    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
6312    /// variable (via `resolve_code_binary` → `focus_window`) —
6313    /// `menu_action_focus_resolves_folder_and_spawns` and
6314    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
6315    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
6316    /// transient overlap under the harness's test parallelism is benign.
6317    struct VscodeBinGuard(Option<std::ffi::OsString>);
6318    impl Drop for VscodeBinGuard {
6319        fn drop(&mut self) {
6320            match self.0.take() {
6321                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6322                None => std::env::remove_var(VSCODE_BIN_ENV),
6323            }
6324        }
6325    }
6326
6327    #[tokio::test]
6328    async fn menu_action_focus_resolves_folder_and_spawns() {
6329        let dir = tempfile::tempdir().unwrap();
6330        let svc = WorktreesService::new();
6331        svc.handle(
6332            "register",
6333            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6334        )
6335        .await
6336        .unwrap();
6337
6338        // Point the launcher at a harmless binary so the spawn deterministically
6339        // succeeds and the focus path returns Ok.
6340        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6341        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6342        svc.menu_action("focus:w1").await.unwrap();
6343    }
6344
6345    #[tokio::test]
6346    async fn open_rejects_missing_relative_or_nonexistent_path() {
6347        let svc = WorktreesService::new();
6348        // A missing `path` is a payload error.
6349        assert!(svc.handle("open", json!({})).await.is_err());
6350        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6351        // A relative path is rejected before any spawn — this is also what
6352        // blocks a `-`-leading argument from reaching `code` as a flag.
6353        assert!(svc
6354            .handle("open", json!({ "path": "relative/dir" }))
6355            .await
6356            .is_err());
6357        assert!(svc
6358            .handle("open", json!({ "path": "-flag" }))
6359            .await
6360            .is_err());
6361        // An absolute path that does not exist is rejected before any spawn, so
6362        // no launcher is needed for these guard cases.
6363        assert!(svc
6364            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6365            .await
6366            .is_err());
6367        svc.shutdown().await;
6368    }
6369
6370    #[tokio::test]
6371    async fn open_focuses_an_existing_absolute_dir() {
6372        let dir = tempfile::tempdir().unwrap();
6373        let svc = WorktreesService::new();
6374        // Pin the launcher to a harmless binary so the spawn deterministically
6375        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6376        // path, `open` takes the folder straight from the payload — no prior
6377        // `register` is required.
6378        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6379        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6380        let reply = svc
6381            .handle("open", json!({ "path": dir.path() }))
6382            .await
6383            .unwrap();
6384        assert_eq!(reply, json!({ "ok": true }));
6385        svc.shutdown().await;
6386    }
6387
6388    #[test]
6389    fn focus_window_with_validates_folder_then_spawns() {
6390        let dir = tempfile::tempdir().unwrap();
6391        // Non-absolute and missing-directory folders are rejected before spawn.
6392        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6393        assert!(
6394            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6395        );
6396        // A valid absolute directory spawns the launcher successfully.
6397        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6398        // A missing launcher surfaces the spawn error (with context), not Ok.
6399        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6400    }
6401
6402    #[test]
6403    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6404        // Env override wins outright.
6405        assert_eq!(
6406            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6407            PathBuf::from("/custom/code")
6408        );
6409        // No override: the first existing candidate is chosen.
6410        let existing = tempfile::NamedTempFile::new().unwrap();
6411        let existing_path = existing.path().to_str().unwrap();
6412        assert_eq!(
6413            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6414            PathBuf::from(existing_path)
6415        );
6416        // Nothing exists: fall back to bare `code` on PATH.
6417        assert_eq!(
6418            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6419            PathBuf::from("code")
6420        );
6421        // The real-env wrapper resolves without panicking.
6422        let _ = resolve_code_binary();
6423    }
6424
6425    // --- Git enrichment (#1186) --------------------------------------------
6426
6427    /// Initializes a fresh repo with a deterministic identity so `commit()`
6428    /// works without depending on a global git config.
6429    fn init_repo(dir: &Path) -> Repository {
6430        let repo = Repository::init(dir).unwrap();
6431        let mut cfg = repo.config().unwrap();
6432        cfg.set_str("user.name", "Test").unwrap();
6433        cfg.set_str("user.email", "test@example.com").unwrap();
6434        repo
6435    }
6436
6437    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6438    /// optionally moving `refname` to it, and returns its oid.
6439    fn empty_commit(
6440        repo: &Repository,
6441        refname: Option<&str>,
6442        parents: &[&git2::Commit<'_>],
6443        msg: &str,
6444    ) -> git2::Oid {
6445        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6446        let tree = repo
6447            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6448            .unwrap();
6449        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6450            .unwrap()
6451    }
6452
6453    /// Commits `content` as file `name` onto `refname`, chaining off its current
6454    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6455    /// the file is checked out into a worktree and can then be modified to
6456    /// produce a dirty (tracked) status.
6457    fn commit_file(
6458        repo: &Repository,
6459        refname: &str,
6460        name: &str,
6461        content: &[u8],
6462        msg: &str,
6463    ) -> git2::Oid {
6464        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6465        let blob = repo.blob(content).unwrap();
6466        let mut builder = repo.treebuilder(None).unwrap();
6467        builder.insert(name, blob, 0o100_644).unwrap();
6468        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6469        let parent = repo
6470            .refname_to_id(refname)
6471            .ok()
6472            .and_then(|oid| repo.find_commit(oid).ok());
6473        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6474        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6475            .unwrap()
6476    }
6477
6478    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6479    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6480    fn diverging_repo(dir: &Path) -> Repository {
6481        let repo = init_repo(dir);
6482        // A: the shared base on `main`.
6483        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6484        let a_commit = repo.find_commit(a).unwrap();
6485        // origin/main diverges to C, a sibling of the local tip.
6486        let c = empty_commit(&repo, None, &[&a_commit], "C");
6487        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6488            .unwrap();
6489        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6490        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6491        // Release the commit's borrow of `repo` so it can be returned.
6492        drop(a_commit);
6493        repo.set_head("refs/heads/main").unwrap();
6494        // Configure the tracking relationship so `upstream()` resolves.
6495        let mut cfg = repo.config().unwrap();
6496        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6497            .unwrap();
6498        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6499            .unwrap();
6500        cfg.set_str("branch.main.remote", "origin").unwrap();
6501        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6502        repo
6503    }
6504
6505    /// Builds a repo whose `main` has **no upstream configured** but is 1 commit
6506    /// behind a resolvable `origin/main` — the "no own upstream, but behind the
6507    /// default branch" case [`folder_main_behind`] exists for (#1457). No
6508    /// `origin/HEAD` symref is set, so resolution goes through
6509    /// [`RemoteInfo::detect_main_branch_local`]'s common-names fallback.
6510    fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6511        let repo = init_repo(dir);
6512        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6513        let a_commit = repo.find_commit(a).unwrap();
6514        let c = empty_commit(&repo, None, &[&a_commit], "C");
6515        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6516            .unwrap();
6517        drop(a_commit);
6518        repo.set_head("refs/heads/main").unwrap();
6519        repo
6520    }
6521
6522    #[test]
6523    fn git_status_reads_branch_and_ahead_behind() {
6524        let dir = tempfile::tempdir().unwrap();
6525        let _repo = diverging_repo(dir.path());
6526        let status = git_status(dir.path());
6527        assert_eq!(status.branch.as_deref(), Some("main"));
6528        assert_eq!(status.ahead, Some(1));
6529        assert_eq!(status.behind, Some(1));
6530        // A normal checkout names itself and is not flagged a worktree.
6531        assert_eq!(
6532            status.main_repo.as_deref(),
6533            dir.path().file_name().and_then(|n| n.to_str())
6534        );
6535        assert!(!status.is_worktree);
6536    }
6537
6538    #[test]
6539    fn git_status_empty_repo_is_unborn() {
6540        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6541        // branch/sync fields stay empty rather than panicking — but the repo
6542        // identity is still resolved from the common dir.
6543        let dir = tempfile::tempdir().unwrap();
6544        init_repo(dir.path());
6545        let status = git_status(dir.path());
6546        assert_eq!(status.branch, None);
6547        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6548        assert_eq!(status.head_sha, None);
6549        assert_eq!(status.ahead, None);
6550        assert_eq!(status.behind, None);
6551        assert_eq!(
6552            status.main_repo.as_deref(),
6553            dir.path().file_name().and_then(|n| n.to_str())
6554        );
6555        assert!(!status.is_worktree);
6556    }
6557
6558    #[test]
6559    fn git_status_no_upstream_reports_branch_only() {
6560        let dir = tempfile::tempdir().unwrap();
6561        let repo = init_repo(dir.path());
6562        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6563        repo.set_head("refs/heads/main").unwrap();
6564        let status = git_status(dir.path());
6565        assert_eq!(status.branch.as_deref(), Some("main"));
6566        // No upstream → ahead/behind stay absent rather than zero.
6567        assert_eq!(status.ahead, None);
6568        assert_eq!(status.behind, None);
6569        // …and so does the upstream SHA, so such a branch still renders with no
6570        // sync indicator at all (#1344).
6571        assert_eq!(status.upstream_sha, None);
6572    }
6573
6574    #[test]
6575    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6576        // A plain directory that is not a git repo yields nothing at all.
6577        let plain = tempfile::tempdir().unwrap();
6578        assert_eq!(git_status(plain.path()), GitStatus::default());
6579
6580        // A detached HEAD reports no branch (and thus no sync), but the repo
6581        // identity is still resolved from the common dir.
6582        let dir = tempfile::tempdir().unwrap();
6583        let repo = init_repo(dir.path());
6584        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6585        repo.set_head_detached(a).unwrap();
6586        let status = git_status(dir.path());
6587        assert_eq!(status.branch, None);
6588        // A detached HEAD has no branch but *does* have a commit — the SHA is
6589        // resolved before the branch filter, so it survives here (#1337).
6590        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6591        assert_eq!(status.ahead, None);
6592        assert_eq!(status.behind, None);
6593        // A detached HEAD has no branch, so there is no upstream to resolve
6594        // either — the branch filter returns before the wrap (#1344).
6595        assert_eq!(status.upstream_sha, None);
6596        assert_eq!(
6597            status.main_repo.as_deref(),
6598            dir.path().file_name().and_then(|n| n.to_str())
6599        );
6600        assert!(!status.is_worktree);
6601    }
6602
6603    // --- Lazy ahead/behind (#1306) -----------------------------------------
6604
6605    #[test]
6606    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6607        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6608        // the streamed tree snapshot still reads the branch and repo identity, but
6609        // leaves ahead/behind absent — divergence is now lazy (#1306).
6610        let dir = tempfile::tempdir().unwrap();
6611        let repo = diverging_repo(dir.path());
6612        let status = git_status_cheap(dir.path());
6613        assert_eq!(status.branch.as_deref(), Some("main"));
6614        assert_eq!(status.ahead, None);
6615        assert_eq!(status.behind, None);
6616        assert_eq!(
6617            status.main_repo.as_deref(),
6618            dir.path().file_name().and_then(|n| n.to_str())
6619        );
6620        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6621        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6622        let head = repo.head().unwrap().target().unwrap();
6623        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6624    }
6625
6626    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6627
6628    #[test]
6629    fn git_status_head_sha_tracks_new_commits() {
6630        // The regression #1337 turns on: a commit must change the status the
6631        // snapshot is built from. Before the SHA rode the payload, committing
6632        // changed nothing on the wire, the server's diff dropped the identical
6633        // snapshot, and no client re-rendered — so a badge computed for the old
6634        // head survived the push that invalidated it.
6635        let dir = tempfile::tempdir().unwrap();
6636        let repo = init_repo(dir.path());
6637        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6638        repo.set_head("refs/heads/main").unwrap();
6639        let before = git_status_cheap(dir.path());
6640        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6641
6642        let head = repo.find_commit(a).unwrap();
6643        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6644        let after = git_status_cheap(dir.path());
6645        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6646        assert_ne!(before.head_sha, after.head_sha);
6647        // The branch is unchanged — the SHA is the *only* thing that moved, which
6648        // is exactly why its absence made the push invisible.
6649        assert_eq!(before.branch, after.branch);
6650    }
6651
6652    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6653
6654    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6655    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6656    /// these tests exercise a push with no network and no second repo.
6657    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6658        repo.reference("refs/remotes/origin/main", oid, true, "push")
6659            .unwrap();
6660    }
6661
6662    #[test]
6663    fn git_status_upstream_sha_tracks_a_push() {
6664        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6665        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6666        // remote-tracking ref, so before this field rode the payload every wire
6667        // field was byte-identical across the push, the server's diff dropped the
6668        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6669        // never re-asked — the row showed `↑1 ↓0` forever.
6670        let dir = tempfile::tempdir().unwrap();
6671        let repo = diverging_repo(dir.path());
6672        let before = git_status(dir.path());
6673        assert_eq!(before.ahead, Some(1));
6674        assert_eq!(before.behind, Some(1));
6675
6676        let head = repo.head().unwrap().target().unwrap();
6677        simulate_push(&repo, head);
6678        let after = git_status(dir.path());
6679
6680        // The upstream now names the pushed commit, and the counts agree.
6681        assert_eq!(
6682            after.upstream_sha.as_deref(),
6683            Some(head.to_string().as_str())
6684        );
6685        assert_ne!(before.upstream_sha, after.upstream_sha);
6686        assert_eq!(after.ahead, Some(0));
6687        assert_eq!(after.behind, Some(0));
6688        // Nothing else moved — which is the whole point. A push leaves the branch
6689        // and the local head exactly where they were, so `upstream_sha` is the
6690        // only signal a client could possibly notice.
6691        assert_eq!(before.branch, after.branch);
6692        assert_eq!(before.head_sha, after.head_sha);
6693    }
6694
6695    #[test]
6696    fn git_status_cheap_reports_upstream_sha() {
6697        // The crux: the field has to ride the *cheap* path, since that is the one
6698        // the streamed snapshot is built from. Costing a config lookup and a refs
6699        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6700        // walk still absent here.
6701        let dir = tempfile::tempdir().unwrap();
6702        let repo = diverging_repo(dir.path());
6703        let status = git_status_cheap(dir.path());
6704        let upstream = repo
6705            .find_branch("origin/main", git2::BranchType::Remote)
6706            .unwrap()
6707            .get()
6708            .target()
6709            .unwrap();
6710        assert_eq!(
6711            status.upstream_sha.as_deref(),
6712            Some(upstream.to_string().as_str())
6713        );
6714        assert_eq!(status.ahead, None);
6715        assert_eq!(status.behind, None);
6716    }
6717
6718    #[test]
6719    fn folder_ahead_behind_computes_divergence_and_degrades() {
6720        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6721        let dir = tempfile::tempdir().unwrap();
6722        let _repo = diverging_repo(dir.path());
6723        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6724
6725        // A branch with no upstream → None (the tree renders no sync indicator).
6726        let no_up = tempfile::tempdir().unwrap();
6727        let repo = init_repo(no_up.path());
6728        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6729        repo.set_head("refs/heads/main").unwrap();
6730        assert_eq!(folder_ahead_behind(no_up.path()), None);
6731
6732        // A detached HEAD and a plain (non-repo) directory → None.
6733        let detached = tempfile::tempdir().unwrap();
6734        let drepo = init_repo(detached.path());
6735        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6736        drepo.set_head_detached(a).unwrap();
6737        assert_eq!(folder_ahead_behind(detached.path()), None);
6738        let plain = tempfile::tempdir().unwrap();
6739        assert_eq!(folder_ahead_behind(plain.path()), None);
6740    }
6741
6742    // --- Lazy main-branch behind (#1457) ------------------------------------
6743
6744    #[test]
6745    fn folder_main_behind_computes_divergence_and_degrades() {
6746        // No own upstream, but a resolvable `origin/main` (via the common-names
6747        // fallback — no `origin/HEAD` symref is set) that the branch is
6748        // genuinely behind.
6749        let dir = tempfile::tempdir().unwrap();
6750        let _repo = behind_main_no_upstream_repo(dir.path());
6751        assert_eq!(folder_main_behind(dir.path()), Some(1));
6752
6753        // A detached HEAD and a plain (non-repo) directory → None.
6754        let detached = tempfile::tempdir().unwrap();
6755        let drepo = init_repo(detached.path());
6756        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6757        drepo.set_head_detached(a).unwrap();
6758        assert_eq!(folder_main_behind(detached.path()), None);
6759        let plain = tempfile::tempdir().unwrap();
6760        assert_eq!(folder_main_behind(plain.path()), None);
6761    }
6762
6763    #[test]
6764    fn folder_main_behind_skips_when_own_upstream_is_the_default_branch() {
6765        // `diverging_repo` checks out `main` tracking `origin/main` itself — the
6766        // common case — so even though it's genuinely 1 behind, `folder_main_behind`
6767        // stays silent: `folder_ahead_behind`'s `behind` already reports this
6768        // exact divergence.
6769        let dir = tempfile::tempdir().unwrap();
6770        let _repo = diverging_repo(dir.path());
6771        assert_eq!(folder_main_behind(dir.path()), None);
6772    }
6773
6774    #[test]
6775    fn folder_main_behind_returns_none_without_a_resolvable_default_branch() {
6776        // No `origin` remote-tracking refs at all (no symref, no common names).
6777        let dir = tempfile::tempdir().unwrap();
6778        let repo = init_repo(dir.path());
6779        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6780        repo.set_head("refs/heads/main").unwrap();
6781        assert_eq!(folder_main_behind(dir.path()), None);
6782    }
6783
6784    #[test]
6785    fn folder_main_behind_and_folder_ahead_behind_report_independent_counts() {
6786        let dir = tempfile::tempdir().unwrap();
6787        let repo = init_repo(dir.path());
6788        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6789        let base_commit = repo.find_commit(base).unwrap();
6790
6791        // origin/main advances 3 commits past the shared base.
6792        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6793        let m1_commit = repo.find_commit(m1).unwrap();
6794        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6795        let m2_commit = repo.find_commit(m2).unwrap();
6796        let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6797        repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6798            .unwrap();
6799
6800        // `feature` branches off the shared base and diverges 1 ahead / 1
6801        // behind its own upstream `origin/feature`.
6802        let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6803        repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6804            .unwrap();
6805        empty_commit(
6806            &repo,
6807            Some("refs/heads/feature"),
6808            &[&base_commit],
6809            "local-feature",
6810        );
6811        drop(base_commit);
6812        drop(m1_commit);
6813        drop(m2_commit);
6814
6815        repo.set_head("refs/heads/feature").unwrap();
6816        let mut cfg = repo.config().unwrap();
6817        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6818            .unwrap();
6819        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6820            .unwrap();
6821        cfg.set_str("branch.feature.remote", "origin").unwrap();
6822        cfg.set_str("branch.feature.merge", "refs/heads/feature")
6823            .unwrap();
6824
6825        assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
6826        assert_eq!(folder_main_behind(dir.path()), Some(3));
6827    }
6828
6829    #[tokio::test]
6830    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
6831        let diverging = tempfile::tempdir().unwrap();
6832        let _d = diverging_repo(diverging.path());
6833        let no_up = tempfile::tempdir().unwrap();
6834        let repo = init_repo(no_up.path());
6835        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6836        repo.set_head("refs/heads/main").unwrap();
6837
6838        let svc = WorktreesService::new();
6839        let diverging_path = diverging.path().display().to_string();
6840        let no_up_path = no_up.path().display().to_string();
6841        let reply = svc
6842            .handle(
6843                "ahead-behind",
6844                json!({ "paths": [&diverging_path, &no_up_path] }),
6845            )
6846            .await
6847            .unwrap();
6848        let results = reply.get("results").unwrap();
6849        // The diverging worktree carries its counts, keyed by the requested path.
6850        // `diverging_repo` checks out `main` tracking `origin/main` itself, so
6851        // `main_behind` is skipped — `behind` already reports this divergence.
6852        let d = results.get(diverging_path.as_str()).unwrap();
6853        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
6854        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
6855        assert!(d.get("main_behind").is_none(), "{d:?}");
6856        // The no-upstream worktree has no `origin` remote-tracking refs at all,
6857        // so neither `ahead`/`behind` nor `main_behind` resolves — the row is
6858        // omitted entirely, not reported as zero.
6859        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
6860
6861        // A missing/empty `paths` list yields an empty results object, not an error.
6862        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
6863        assert_eq!(empty.get("results"), Some(&json!({})));
6864    }
6865
6866    #[tokio::test]
6867    async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
6868        let dir = tempfile::tempdir().unwrap();
6869        let _repo = behind_main_no_upstream_repo(dir.path());
6870        let svc = WorktreesService::new();
6871        let path = dir.path().display().to_string();
6872        let reply = svc
6873            .handle("ahead-behind", json!({ "paths": [&path] }))
6874            .await
6875            .unwrap();
6876        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6877        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
6878        // No own upstream at all → no `ahead`/`behind` keys, just `main_behind`.
6879        assert!(entry.get("ahead").is_none(), "{entry:?}");
6880        assert!(entry.get("behind").is_none(), "{entry:?}");
6881    }
6882
6883    #[tokio::test]
6884    async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
6885        // `release` stays perfectly in sync with its own upstream
6886        // `origin/release`, while `origin/main` has independently advanced 2
6887        // commits past their shared base — `main_behind` must still fold in
6888        // even though `ahead`/`behind` are both zero.
6889        let dir = tempfile::tempdir().unwrap();
6890        let repo = init_repo(dir.path());
6891        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6892        let base_commit = repo.find_commit(base).unwrap();
6893
6894        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6895        let m1_commit = repo.find_commit(m1).unwrap();
6896        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6897        repo.reference("refs/remotes/origin/main", m2, true, "origin main")
6898            .unwrap();
6899
6900        let r = empty_commit(
6901            &repo,
6902            Some("refs/heads/release"),
6903            &[&base_commit],
6904            "release",
6905        );
6906        repo.reference("refs/remotes/origin/release", r, true, "origin release")
6907            .unwrap();
6908        drop(base_commit);
6909        drop(m1_commit);
6910
6911        repo.set_head("refs/heads/release").unwrap();
6912        let mut cfg = repo.config().unwrap();
6913        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6914            .unwrap();
6915        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6916            .unwrap();
6917        cfg.set_str("branch.release.remote", "origin").unwrap();
6918        cfg.set_str("branch.release.merge", "refs/heads/release")
6919            .unwrap();
6920
6921        let svc = WorktreesService::new();
6922        let path = dir.path().display().to_string();
6923        let reply = svc
6924            .handle("ahead-behind", json!({ "paths": [&path] }))
6925            .await
6926            .unwrap();
6927        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
6928        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
6929        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
6930        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
6931    }
6932
6933    #[tokio::test]
6934    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
6935        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
6936        let dir = tempfile::tempdir().unwrap();
6937        let _repo = diverging_repo(dir.path());
6938        let svc = WorktreesService::new();
6939        svc.handle(
6940            "register",
6941            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6942        )
6943        .await
6944        .unwrap();
6945
6946        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
6947        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
6948        let main_wt = &worktrees[0];
6949        // The cheap parts are present, but divergence is not — it is fetched
6950        // lazily via the `ahead-behind` op (#1306).
6951        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
6952        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
6953        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
6954    }
6955
6956    #[tokio::test]
6957    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
6958        // The end-to-end shape of the #1337 freshness fix. The server pushes a
6959        // snapshot only when it differs from the last one
6960        // (`server.rs`: `if snap != last`), so anything invisible on the wire
6961        // cannot trigger a re-render. Committing must therefore move the payload.
6962        let dir = tempfile::tempdir().unwrap();
6963        let repo = init_repo(dir.path());
6964        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6965        repo.set_head("refs/heads/main").unwrap();
6966
6967        let svc = WorktreesService::new();
6968        svc.handle(
6969            "register",
6970            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
6971        )
6972        .await
6973        .unwrap();
6974
6975        let before = svc.handle("tree", Value::Null).await.unwrap();
6976        let wt = &repos_of(&before)[0]["worktrees"][0];
6977        assert_eq!(
6978            wt.get("head_sha").and_then(Value::as_str),
6979            Some(a.to_string().as_str())
6980        );
6981
6982        // Commit again: same branch, same paths, same open windows — pre-#1337 the
6983        // snapshot was byte-identical here and the push was dropped.
6984        let head = repo.find_commit(a).unwrap();
6985        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6986        let after = svc.handle("tree", Value::Null).await.unwrap();
6987        assert_eq!(
6988            repos_of(&after)[0]["worktrees"][0]
6989                .get("head_sha")
6990                .and_then(Value::as_str),
6991            Some(b.to_string().as_str())
6992        );
6993        assert_ne!(before, after, "a commit must be a visible snapshot delta");
6994    }
6995
6996    #[tokio::test]
6997    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
6998        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
6999        // matching the payload's `skip_serializing_if` convention.
7000        let dir = tempfile::tempdir().unwrap();
7001        init_repo(dir.path());
7002        let svc = WorktreesService::new();
7003        svc.handle(
7004            "register",
7005            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7006        )
7007        .await
7008        .unwrap();
7009        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7010        assert!(wt.get("head_sha").is_none(), "{wt:?}");
7011    }
7012
7013    // --- Upstream SHA on the snapshot (#1344) ------------------------------
7014
7015    #[tokio::test]
7016    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7017        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
7018        // moves neither the branch nor the local head, so `upstream_sha` is the
7019        // only field that can carry the news. Without it the snapshot serialised
7020        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
7021        // window re-rendered, and the lazy ahead/behind was never re-fetched.
7022        let dir = tempfile::tempdir().unwrap();
7023        let repo = diverging_repo(dir.path());
7024        let svc = WorktreesService::new();
7025        svc.handle(
7026            "register",
7027            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7028        )
7029        .await
7030        .unwrap();
7031
7032        let before = svc.handle("tree", Value::Null).await.unwrap();
7033        let head = repo.head().unwrap().target().unwrap();
7034        assert_ne!(
7035            repos_of(&before)[0]["worktrees"][0]
7036                .get("upstream_sha")
7037                .and_then(Value::as_str),
7038            Some(head.to_string().as_str()),
7039            "the fixture must start un-pushed for this to prove anything"
7040        );
7041
7042        // Push: only `refs/remotes/origin/main` moves.
7043        simulate_push(&repo, head);
7044        let after = svc.handle("tree", Value::Null).await.unwrap();
7045        let wt = &repos_of(&after)[0]["worktrees"][0];
7046        assert_eq!(
7047            wt.get("upstream_sha").and_then(Value::as_str),
7048            Some(head.to_string().as_str())
7049        );
7050        // The head and branch are untouched across the push — so this delta rests
7051        // entirely on `upstream_sha`.
7052        assert_eq!(
7053            wt.get("head_sha").and_then(Value::as_str),
7054            repos_of(&before)[0]["worktrees"][0]
7055                .get("head_sha")
7056                .and_then(Value::as_str)
7057        );
7058        assert_ne!(before, after, "a push must be a visible snapshot delta");
7059    }
7060
7061    #[tokio::test]
7062    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7063        // Wire-compat, and the no-regression case: a branch tracking nothing sends
7064        // no key at all rather than a null, so an older client sees exactly the
7065        // payload it saw before and still renders no sync indicator.
7066        let dir = tempfile::tempdir().unwrap();
7067        let repo = init_repo(dir.path());
7068        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7069        repo.set_head("refs/heads/main").unwrap();
7070        let svc = WorktreesService::new();
7071        svc.handle(
7072            "register",
7073            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7074        )
7075        .await
7076        .unwrap();
7077        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7078        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7079        // The head still rides, so this is specifically the upstream degrading.
7080        assert!(wt.get("head_sha").is_some(), "{wt:?}");
7081    }
7082
7083    // --- PR badge poller (#1337) -------------------------------------------
7084
7085    /// Writes an executable stub that ignores its arguments and prints `stdout`,
7086    /// standing in for `gh api graphql` so the poll loop is exercised offline.
7087    /// Returns the shim lock alongside the path: the caller **must** hold the
7088    /// guard until the poller has finished exec'ing the stub. Writing an
7089    /// executable and then `execve`ing it races every other thread that forks —
7090    /// the child inherits the still-open writable FD and the exec fails
7091    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
7092    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7093        let guard = shim_lock();
7094        let path = dir.join("fake-gh");
7095        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7096        (path, guard)
7097    }
7098
7099    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
7100    /// byte to a counter file before printing `stdout`. The returned counter path
7101    /// lets a test assert how many `gh` subprocesses actually ran, independent of
7102    /// the #1387 request-log counter — so the two can be compared (#1389).
7103    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7104        let guard = shim_lock();
7105        let path = dir.join("fake-gh");
7106        let counter = dir.join("gh-calls");
7107        write_exec_script(
7108            &path,
7109            &format!(
7110                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7111                counter = counter.display()
7112            ),
7113        );
7114        (path, guard, counter)
7115    }
7116
7117    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
7118    /// ran) — the length of the counter file.
7119    fn gh_spawn_count(counter: &Path) -> usize {
7120        std::fs::read(counter).map_or(0, |b| b.len())
7121    }
7122
7123    /// The number of **successful** `kind: "gh"` records the #1387 choke point
7124    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
7125    /// on the exit code matches the ground-truth counter (which is written when the
7126    /// stub *runs*), so a rare failed spawn cannot desync the two.
7127    fn counted_gh_records(log: &Path) -> usize {
7128        std::fs::read_to_string(log)
7129            .unwrap_or_default()
7130            .lines()
7131            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7132            .count()
7133    }
7134
7135    /// A repo with a GitHub origin and one commit on `main`.
7136    fn github_repo(dir: &Path) -> Repository {
7137        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7138    }
7139
7140    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
7141    /// test can register **distinct** targets (owner/name) across windows.
7142    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7143        let repo = init_repo(dir);
7144        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7145        repo.set_head("refs/heads/main").unwrap();
7146        repo.remote("origin", url).unwrap();
7147        repo
7148    }
7149
7150    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
7151    /// because the fold downgrades a badge naming a different commit than the
7152    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
7153    /// wrong reason.
7154    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7155        PrBadge {
7156            number,
7157            is_draft: false,
7158            checks: PrCheckState::Pending,
7159            url: "u".into(),
7160            head_oid: head_oid.to_string(),
7161        }
7162    }
7163
7164    /// Wraps a badge as the cache's resolution value (#1370).
7165    fn pr(badge: PrBadge) -> PrResolution {
7166        PrResolution::Pr(badge)
7167    }
7168
7169    #[test]
7170    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
7171        let snapshot = json!({"repos":[
7172            {
7173                "main_repo":"omni-dev",
7174                "github":{"owner":"rust-works","name":"omni-dev"},
7175                "root":"/r",
7176                // Enabled (#1376) — a not-polled repo contributes no targets (see
7177                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
7178                "polling_enabled":true,
7179                // Two worktrees on the same branch must ask once, not twice.
7180                "worktrees":[
7181                    {"path":"/r","branch":"main","is_main":true,"open":true},
7182                    {"path":"/w1","branch":"main","is_main":false,"open":true},
7183                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
7184                    // Detached: no branch, so nothing to resolve.
7185                    {"path":"/w3","is_main":false,"open":true}
7186                ]
7187            },
7188            {
7189                // Not on GitHub: contributes no targets at all.
7190                "main_repo":"local","root":"/l",
7191                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
7192            }
7193        ]});
7194        let targets = pr_targets_from_snapshot(&snapshot);
7195        assert_eq!(
7196            targets,
7197            vec![
7198                PrTarget {
7199                    owner: "rust-works".into(),
7200                    name: "omni-dev".into(),
7201                    branch: "feature".into()
7202                },
7203                PrTarget {
7204                    owner: "rust-works".into(),
7205                    name: "omni-dev".into(),
7206                    branch: "main".into()
7207                },
7208            ]
7209        );
7210    }
7211
7212    #[test]
7213    fn pr_targets_from_snapshot_is_empty_without_repos() {
7214        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
7215        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
7216    }
7217
7218    #[test]
7219    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
7220        // Defensive: a `github` object without usable owner/name strings yields no
7221        // target rather than a half-built query.
7222        for github in [
7223            json!({}),
7224            json!({"owner": "o"}),
7225            json!({"owner": 1, "name": 2}),
7226        ] {
7227            let snapshot = json!({"repos":[{
7228                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
7229                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7230            }]});
7231            assert!(
7232                pr_targets_from_snapshot(&snapshot).is_empty(),
7233                "{snapshot:?}"
7234            );
7235        }
7236    }
7237
7238    #[test]
7239    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
7240        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
7241        // absent (the default-off case) or explicitly false contributes no watch,
7242        // so the poll never mentions it. Only an enabled repo is polled.
7243        for repo in [
7244            json!({
7245                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7246                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7247            }),
7248            json!({
7249                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7250                "polling_enabled":false,
7251                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7252            }),
7253        ] {
7254            let snapshot = json!({ "repos": [repo] });
7255            assert!(
7256                pr_targets_from_snapshot(&snapshot).is_empty(),
7257                "not-polled repo must yield no targets: {snapshot:?}"
7258            );
7259        }
7260        // Flipping the same repo to enabled makes it contribute.
7261        let enabled = json!({"repos":[{
7262            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
7263            "polling_enabled":true,
7264            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
7265        }]});
7266        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
7267    }
7268
7269    #[test]
7270    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
7271        let backoff = Duration::from_secs(600);
7272        // Never fetched: go.
7273        assert!(pr_should_fetch(false, None, backoff));
7274        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
7275        // spend nothing.
7276        assert!(!pr_should_fetch(
7277            false,
7278            Some(Duration::from_secs(1)),
7279            backoff
7280        ));
7281        // Quiet tree, backoff elapsed: time to look again.
7282        assert!(pr_should_fetch(false, Some(backoff), backoff));
7283        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
7284        // The load-bearing case: the watch grew (a target added, or an upstream
7285        // pushed), so fetch **now** regardless of how deep the backoff had grown.
7286        // Without this a push waits out the full ceiling on a stale badge.
7287        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
7288        assert!(pr_should_fetch(
7289            true,
7290            Some(Duration::from_millis(1)),
7291            backoff
7292        ));
7293    }
7294
7295    #[test]
7296    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
7297        let base = Duration::from_secs(10);
7298        let fresh = Some(Duration::ZERO);
7299        let stale = Some(PENDING_FAST_WINDOW);
7300        // Pending and fresh (within the fast window): hold `base`, however long we
7301        // had backed off for.
7302        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
7303        assert_eq!(
7304            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
7305            base
7306        );
7307        // Pending but past the fast window (a long CI run): escalate — double up to
7308        // the pending ceiling, never to the terminal one.
7309        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
7310        assert_eq!(
7311            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
7312            PENDING_MAX_INTERVAL
7313        );
7314        // Pending with nothing having moved yet (`None`) is treated as past the fast
7315        // window, so a stale-from-boot pending state does not pin `base`.
7316        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
7317        // Everything terminal: double…
7318        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
7319        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
7320        // …up to the terminal ceiling (above the pending one), and never overflow.
7321        assert_eq!(
7322            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
7323            MAX_PR_POLL_INTERVAL
7324        );
7325        assert_eq!(
7326            next_pr_poll_delay(Duration::MAX, base, false, None),
7327            MAX_PR_POLL_INTERVAL
7328        );
7329    }
7330
7331    /// A watch on one branch with the given upstream tip.
7332    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
7333        PrWatch {
7334            target: PrTarget {
7335                owner: "rust-works".into(),
7336                name: "omni-dev".into(),
7337                branch: branch.into(),
7338            },
7339            upstream_sha: upstream.map(str::to_string),
7340        }
7341    }
7342
7343    #[test]
7344    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
7345        let a = watch("a", Some("111"));
7346        let b = watch("b", Some("222"));
7347        let ab = [a.clone(), b.clone()];
7348        let just_a = std::slice::from_ref(&a);
7349        let just_b = std::slice::from_ref(&b);
7350        // Nothing new: quiet tick.
7351        assert!(!pr_watch_grew(&ab, &ab));
7352        // Addition: a new target appeared.
7353        assert!(pr_watch_grew(just_a, &ab));
7354        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
7355        assert!(!pr_watch_grew(&ab, just_a));
7356        // First sight (empty prev, from `None`): everything is new.
7357        assert!(pr_watch_grew(&[], just_a));
7358        // A push moves only the upstream — still "grew".
7359        let a_pushed = [watch("a", Some("999"))];
7360        assert!(pr_watch_grew(just_a, &a_pushed));
7361        // Gaining a target while losing another still fetches (the gain wins).
7362        assert!(pr_watch_grew(just_a, just_b));
7363    }
7364
7365    #[test]
7366    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
7367        let base = Duration::from_secs(10);
7368        let over = RateLimitSnapshot {
7369            graphql: Some(rl_resource(90)),
7370            core: Some(rl_resource(3)),
7371            search: None,
7372        };
7373        let under = RateLimitSnapshot {
7374            graphql: Some(rl_resource(50)),
7375            core: Some(rl_resource(3)),
7376            search: None,
7377        };
7378        // No reading yet: unchanged.
7379        assert_eq!(budget_throttled_delay(base, None), base);
7380        // Under the warn threshold: unchanged.
7381        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
7382        // Over: raised to at least the throttle floor…
7383        assert_eq!(
7384            budget_throttled_delay(base, Some(&over)),
7385            BUDGET_THROTTLE_INTERVAL
7386        );
7387        // …but a delay already above the floor is left alone (never shortened).
7388        let long = BUDGET_THROTTLE_INTERVAL * 2;
7389        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
7390    }
7391
7392    #[test]
7393    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
7394        // The persisted cache must survive a JSON round trip with `head_oid` intact
7395        // — it is the staleness key the tree wire drops, and losing it would render
7396        // every restored badge stale (#1389, fix 4).
7397        let target = PrTarget {
7398            owner: "rust-works".into(),
7399            name: "omni-dev".into(),
7400            branch: "main".into(),
7401        };
7402        let badge = PrResolution::Pr(PrBadge {
7403            number: 1337,
7404            is_draft: true,
7405            checks: PrCheckState::Pending,
7406            url: "http://x/1337".into(),
7407            head_oid: "deadbeef".into(),
7408        });
7409        let watched = vec![watch("main", Some("abc"))];
7410        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
7411            .unwrap()
7412            .with_timezone(&Utc);
7413        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
7414
7415        let json = serde_json::to_vec(&prefs).unwrap();
7416        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7417        assert_eq!(back, prefs);
7418        assert_eq!(back.polled_at, Some(polled_at));
7419        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
7420        // The restored resolution equals the original — head_oid and all.
7421        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
7422    }
7423
7424    #[test]
7425    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
7426        // The explicit negative must survive persistence too: restoring `NoPr`
7427        // as "absent" would lose the #1370 distinction across a restart and
7428        // re-ask GitHub for branches already known to have no PR.
7429        let target = PrTarget {
7430            owner: "rust-works".into(),
7431            name: "omni-dev".into(),
7432            branch: "feature".into(),
7433        };
7434        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
7435        let json = serde_json::to_vec(&prefs).unwrap();
7436        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
7437        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
7438        assert_eq!(
7439            back.entries[0].resolution.clone().into_resolution(),
7440            PrResolution::NoPr
7441        );
7442    }
7443
7444    #[test]
7445    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
7446        // A file with verdicts but no `polled_at` (an older shape, or a
7447        // hand-edited one) still renders badges, but must not arm the warm
7448        // start: without a poll time there is nothing to age the verdicts
7449        // against, so the poller re-polls immediately (#1389, fix 4).
7450        let dir = tempfile::tempdir().unwrap();
7451        let path = dir.path().join("pr-cache.json");
7452        let target = PrTarget {
7453            owner: "rust-works".into(),
7454            name: "omni-dev".into(),
7455            branch: "main".into(),
7456        };
7457        let mut prefs = pr_cache_prefs_from(
7458            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
7459            &[watch("main", None)],
7460            Utc::now(),
7461        );
7462        prefs.polled_at = None;
7463        write_pr_cache(&path, &prefs).unwrap();
7464
7465        let svc = WorktreesService::new();
7466        svc.load_pr_cache(path);
7467        assert!(
7468            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
7469            "the badge itself must still restore"
7470        );
7471        assert!(
7472            svc.pr_warm_start
7473                .lock()
7474                .unwrap_or_else(PoisonError::into_inner)
7475                .is_none(),
7476            "no poll time means a cold start, not a trusted warm one"
7477        );
7478    }
7479
7480    /// Installs a thread-local WARN-level subscriber for the duration of a
7481    /// test, so degraded-path `tracing::warn!` sites actually format their
7482    /// fields instead of short-circuiting on "nobody is listening".
7483    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
7484        tracing::subscriber::set_default(
7485            tracing_subscriber::fmt()
7486                .with_max_level(tracing::Level::WARN)
7487                .with_writer(std::io::sink)
7488                .finish(),
7489        )
7490    }
7491
7492    #[test]
7493    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
7494        // The best-effort contract: a mangled cache is logged and treated as
7495        // empty — never a panic — and the path is stored regardless, so the
7496        // next successful poll rewrites a clean file (#1389, fix 4).
7497        let _trace = warn_subscriber();
7498        let dir = tempfile::tempdir().unwrap();
7499        let corrupt = dir.path().join("pr-cache.json");
7500        std::fs::write(&corrupt, b"not json").unwrap();
7501        let svc = WorktreesService::new();
7502        svc.load_pr_cache(corrupt.clone());
7503        assert!(svc.pr_cache.entries().is_empty());
7504        assert_eq!(
7505            svc.pr_cache_path
7506                .lock()
7507                .unwrap_or_else(PoisonError::into_inner)
7508                .as_deref(),
7509            Some(corrupt.as_path()),
7510            "the path must be stored even when the load fails, so persistence recovers"
7511        );
7512
7513        // A directory: `read` fails with a non-NotFound error (the distinct
7514        // "could not read" arm), with the same treated-as-empty outcome.
7515        let svc = WorktreesService::new();
7516        svc.load_pr_cache(dir.path().to_path_buf());
7517        assert!(svc.pr_cache.entries().is_empty());
7518    }
7519
7520    #[test]
7521    fn persist_pr_cache_swallows_a_write_failure() {
7522        // Best-effort: an unwritable path is logged at WARN and swallowed — the
7523        // in-memory cache stays authoritative, and losing the warm start only
7524        // costs one extra poll after the next restart.
7525        let _trace = warn_subscriber();
7526        let dir = tempfile::tempdir().unwrap();
7527        let blocker = dir.path().join("blocker");
7528        std::fs::write(&blocker, b"").unwrap();
7529        // The parent is a regular file, so creating the runtime dir must fail.
7530        let path = blocker.join("pr-cache.json");
7531        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
7532        assert!(!path.exists());
7533        // The root has no parent at all — nothing to create, and the write
7534        // itself fails (it is a directory); still swallowed.
7535        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
7536    }
7537
7538    #[tokio::test]
7539    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
7540        let dir = tempfile::tempdir().unwrap();
7541        let repo = github_repo(dir.path());
7542        let head = repo.head().unwrap().target().unwrap().to_string();
7543        let svc = WorktreesService::new();
7544        svc.handle(
7545            "register",
7546            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7547        )
7548        .await
7549        .unwrap();
7550        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7551        // act on it, as if the user had toggled it on.
7552        svc.registry.set_polling("rust-works", "omni-dev", true);
7553
7554        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
7555        // and so is the negative, so "not resolved" stays distinguishable (#1370).
7556        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7557        assert!(wt.get("pr").is_none(), "{wt:?}");
7558        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7559
7560        // Seed the cache the poller writes, then re-read the tree.
7561        let mut badges = HashMap::new();
7562        badges.insert(
7563            PrTarget {
7564                owner: "rust-works".into(),
7565                name: "omni-dev".into(),
7566                branch: "main".into(),
7567            },
7568            pr(pending_badge(1337, &head)),
7569        );
7570        assert!(svc.pr_cache.replace(badges));
7571
7572        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7573        assert_eq!(wt["pr"]["number"], json!(1337));
7574        assert_eq!(wt["pr"]["checks"], json!("pending"));
7575        // camelCase on the wire, or the extension silently loses the draft marker.
7576        assert_eq!(wt["pr"]["isDraft"], json!(false));
7577        // A badge and the negative are mutually exclusive.
7578        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7579    }
7580
7581    #[tokio::test]
7582    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
7583        // A detached HEAD has a commit but no branch, and a badge is keyed by
7584        // branch — so there is nothing to match. It must fall through silently
7585        // rather than borrow a badge from whatever branch happens to be cached, and
7586        // rather than sink the tree.
7587        let dir = tempfile::tempdir().unwrap();
7588        let repo = github_repo(dir.path());
7589        let head = repo.head().unwrap().target().unwrap();
7590        repo.set_head_detached(head).unwrap();
7591
7592        let svc = WorktreesService::new();
7593        svc.handle(
7594            "register",
7595            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7596        )
7597        .await
7598        .unwrap();
7599        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7600        // act on it, as if the user had toggled it on.
7601        svc.registry.set_polling("rust-works", "omni-dev", true);
7602        // A badge *is* cached for `main` — the branch this worktree was on before
7603        // detaching. It must not leak onto the now-branchless row.
7604        let mut badges = HashMap::new();
7605        badges.insert(
7606            PrTarget {
7607                owner: "rust-works".into(),
7608                name: "omni-dev".into(),
7609                branch: "main".into(),
7610            },
7611            pr(pending_badge(1, &head.to_string())),
7612        );
7613        svc.pr_cache.replace(badges);
7614
7615        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7616        assert!(wt.get("branch").is_none(), "{wt:?}");
7617        // The SHA still shows — detached means no branch, not no commit.
7618        assert_eq!(
7619            wt.get("head_sha").and_then(Value::as_str),
7620            Some(head.to_string().as_str())
7621        );
7622        assert!(wt.get("pr").is_none(), "{wt:?}");
7623        // No branch means nothing was checked either: no negative on the row.
7624        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7625    }
7626
7627    #[tokio::test]
7628    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
7629        let dir = tempfile::tempdir().unwrap();
7630        github_repo(dir.path());
7631        let svc = WorktreesService::new();
7632        svc.handle(
7633            "register",
7634            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7635        )
7636        .await
7637        .unwrap();
7638        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7639        // act on it, as if the user had toggled it on.
7640        svc.registry.set_polling("rust-works", "omni-dev", true);
7641        // A badge for a different branch must not leak onto `main`.
7642        let mut badges = HashMap::new();
7643        badges.insert(
7644            PrTarget {
7645                owner: "rust-works".into(),
7646                name: "omni-dev".into(),
7647                branch: "other".into(),
7648            },
7649            pr(pending_badge(1, "irrelevant")),
7650        );
7651        svc.pr_cache.replace(badges);
7652        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7653        assert!(wt.get("pr").is_none(), "{wt:?}");
7654        // An unmatched branch is unresolved, not negative.
7655        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7656    }
7657
7658    #[tokio::test]
7659    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
7660        // The #1370 fix on the wire: a branch the poller checked and found PR-less
7661        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
7662        // tell "checked, none" from "not resolved" and keep its fallback quiet.
7663        let dir = tempfile::tempdir().unwrap();
7664        github_repo(dir.path());
7665        let svc = WorktreesService::new();
7666        svc.handle(
7667            "register",
7668            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7669        )
7670        .await
7671        .unwrap();
7672        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7673        // act on it, as if the user had toggled it on.
7674        svc.registry.set_polling("rust-works", "omni-dev", true);
7675
7676        let mut resolutions = HashMap::new();
7677        resolutions.insert(
7678            PrTarget {
7679                owner: "rust-works".into(),
7680                name: "omni-dev".into(),
7681                branch: "main".into(),
7682            },
7683            PrResolution::NoPr,
7684        );
7685        assert!(svc.pr_cache.replace(resolutions));
7686
7687        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7688        assert_eq!(wt["pr_none"], json!(true));
7689        // Mutually exclusive with a badge.
7690        assert!(wt.get("pr").is_none(), "{wt:?}");
7691    }
7692
7693    #[tokio::test]
7694    async fn a_commit_does_not_drop_a_negative_resolution() {
7695        // A negative has no commit to be stale against. Dropping it when HEAD
7696        // moves would flip the row back to "unresolved" on every local commit —
7697        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
7698        // poller's `moved` trigger re-checks the branch promptly instead.
7699        let dir = tempfile::tempdir().unwrap();
7700        let repo = github_repo(dir.path());
7701        let first = repo.head().unwrap().target().unwrap();
7702
7703        let svc = WorktreesService::new();
7704        svc.handle(
7705            "register",
7706            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7707        )
7708        .await
7709        .unwrap();
7710        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7711        // act on it, as if the user had toggled it on.
7712        svc.registry.set_polling("rust-works", "omni-dev", true);
7713
7714        let mut resolutions = HashMap::new();
7715        resolutions.insert(
7716            PrTarget {
7717                owner: "rust-works".into(),
7718                name: "omni-dev".into(),
7719                branch: "main".into(),
7720            },
7721            PrResolution::NoPr,
7722        );
7723        svc.pr_cache.replace(resolutions);
7724
7725        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726        assert_eq!(wt["pr_none"], json!(true));
7727
7728        // Commit — as a push would leave things, with the cache untouched.
7729        let head = repo.find_commit(first).unwrap();
7730        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7731
7732        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7733        assert_eq!(
7734            wt["pr_none"],
7735            json!(true),
7736            "a local commit must not drop the negative"
7737        );
7738    }
7739
7740    #[tokio::test]
7741    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
7742        // The idle case — the daemon runs all day with no editor open. Point it at a
7743        // stub that fails loudly if ever spawned: a poll here would both waste
7744        // GitHub budget and, on a real `gh`, wake the radio for nothing.
7745        let bin_dir = tempfile::tempdir().unwrap();
7746        let marker = bin_dir.path().join("spawned");
7747        let fake = bin_dir.path().join("fake-gh");
7748        std::fs::write(
7749            &fake,
7750            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
7751        )
7752        .unwrap();
7753        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7754        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7755        std::fs::set_permissions(&fake, perms).unwrap();
7756
7757        let svc = WorktreesService::new();
7758        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7759        tokio::time::sleep(Duration::from_millis(200)).await;
7760        svc.shutdown().await;
7761        assert!(
7762            !marker.exists(),
7763            "the poller must not spawn gh with no windows registered"
7764        );
7765    }
7766
7767    #[tokio::test]
7768    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
7769        // Badges are decoration: an unauthenticated or broken `gh` must never sink
7770        // the tree, and one bad poll must not blank rows that were fine a second ago.
7771        let dir = tempfile::tempdir().unwrap();
7772        let repo = github_repo(dir.path());
7773        let head = repo.head().unwrap().target().unwrap().to_string();
7774        let bin_dir = tempfile::tempdir().unwrap();
7775        let fake = bin_dir.path().join("fake-gh");
7776        // Exits non-zero, exactly as `gh` does without `gh auth login`.
7777        std::fs::write(
7778            &fake,
7779            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
7780        )
7781        .unwrap();
7782        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
7783        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
7784        std::fs::set_permissions(&fake, perms).unwrap();
7785
7786        let svc = WorktreesService::new();
7787        svc.handle(
7788            "register",
7789            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7790        )
7791        .await
7792        .unwrap();
7793        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7794        // act on it, as if the user had toggled it on.
7795        svc.registry.set_polling("rust-works", "omni-dev", true);
7796        // Seed a badge as though an earlier poll had succeeded.
7797        let mut seeded = HashMap::new();
7798        seeded.insert(
7799            PrTarget {
7800                owner: "rust-works".into(),
7801                name: "omni-dev".into(),
7802                branch: "main".into(),
7803            },
7804            pr(pending_badge(7, &head)),
7805        );
7806        svc.pr_cache.replace(seeded);
7807
7808        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
7809        tokio::time::sleep(Duration::from_millis(200)).await;
7810
7811        // The tree still serves, and the seeded badge survived the failing polls —
7812        // which also minted no false "no PR" negatives (#1370).
7813        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7814        assert_eq!(wt["pr"]["number"], json!(7));
7815        assert!(wt.get("pr_none").is_none(), "{wt:?}");
7816        svc.shutdown().await;
7817    }
7818
7819    #[tokio::test]
7820    // The shim guard is deliberately held across the awaits below: it must span
7821    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
7822    // is against another test writing while this one forks. Safe here — only test
7823    // threads take it, never a task inside the runtime, so it cannot deadlock.
7824    // Scoped per-test rather than on the module, which would also silence the
7825    // registry lock's "never held across .await" invariant.
7826    #[allow(clippy::await_holding_lock)]
7827    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
7828        // The normal startup order: the daemon starts at login, *before* any
7829        // editor. It therefore sees an empty tree and backs off to the 30-minute
7830        // ceiling — so unless a register wakes it, the first badge of the session
7831        // arrives up to half an hour after the window does, which reads as the
7832        // feature being broken rather than slow.
7833        let dir = tempfile::tempdir().unwrap();
7834        github_repo(dir.path());
7835        let bin_dir = tempfile::tempdir().unwrap();
7836        let (fake, _shim) = fake_gh(
7837            bin_dir.path(),
7838            r#"{"data":{"r0":{"b0":{
7839                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
7840                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
7841                ]}}},
7842                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
7843            }}}}"#,
7844        );
7845
7846        let svc = WorktreesService::new();
7847        // Poller first, with nothing registered — it backs off on the empty tree.
7848        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
7849        tokio::time::sleep(Duration::from_millis(150)).await;
7850
7851        // Now an editor opens.
7852        svc.handle(
7853            "register",
7854            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7855        )
7856        .await
7857        .unwrap();
7858        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7859        // act on it, as if the user had toggled it on.
7860        svc.registry.set_polling("rust-works", "omni-dev", true);
7861
7862        // The badge must follow promptly — the register wakes the loop out of its
7863        // backoff. The deadline is orders of magnitude below the ceiling, so this
7864        // fails on the bug rather than merely being slow.
7865        let badge = tokio::time::timeout(Duration::from_secs(30), async {
7866            loop {
7867                if let Some(PrResolution::Pr(badge)) =
7868                    svc.pr_cache.get("rust-works", "omni-dev", "main")
7869                {
7870                    return badge;
7871                }
7872                tokio::time::sleep(Duration::from_millis(25)).await;
7873            }
7874        })
7875        .await
7876        .expect("a window opening must wake the poller out of its idle backoff");
7877        assert_eq!(badge.number, 99);
7878        svc.shutdown().await;
7879    }
7880
7881    #[tokio::test]
7882    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
7883        // The acceptance criterion: "pushing a new commit invalidates the badge
7884        // rather than leaving the previous head's verdict standing."
7885        //
7886        // The cache still holds the verdict for the *old* commit, and the poller may
7887        // have backed off for up to half an hour. So the fold — which runs on every
7888        // snapshot — has to notice on its own, with no network call.
7889        let dir = tempfile::tempdir().unwrap();
7890        let repo = github_repo(dir.path());
7891        let first = repo.head().unwrap().target().unwrap();
7892
7893        let svc = WorktreesService::new();
7894        svc.handle(
7895            "register",
7896            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
7897        )
7898        .await
7899        .unwrap();
7900        // Polling defaults off (#1376): enable it for this repo so the poller/fold
7901        // act on it, as if the user had toggled it on.
7902        svc.registry.set_polling("rust-works", "omni-dev", true);
7903
7904        // A green verdict, correctly describing the commit currently checked out.
7905        let mut badges = HashMap::new();
7906        badges.insert(
7907            PrTarget {
7908                owner: "rust-works".into(),
7909                name: "omni-dev".into(),
7910                branch: "main".into(),
7911            },
7912            pr(PrBadge {
7913                number: 1337,
7914                is_draft: false,
7915                checks: PrCheckState::Success,
7916                url: "u".into(),
7917                head_oid: first.to_string(),
7918            }),
7919        );
7920        svc.pr_cache.replace(badges);
7921
7922        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7923        assert_eq!(
7924            wt["pr"]["checks"],
7925            json!("success"),
7926            "green for its own commit"
7927        );
7928
7929        // Now commit — as a push would leave things, with the cache untouched.
7930        let head = repo.find_commit(first).unwrap();
7931        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7932
7933        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7934        assert_eq!(
7935            wt["pr"]["checks"],
7936            json!("pending"),
7937            "the previous commit's ✓ must not stand after a new commit"
7938        );
7939        // The PR itself is still shown — it is the *verdict* that is unknown, not
7940        // the PR.
7941        assert_eq!(wt["pr"]["number"], json!(1337));
7942    }
7943
7944    #[test]
7945    fn is_stale_for_compares_the_commit_the_verdict_describes() {
7946        let badge = pending_badge(1, "aaa");
7947        assert!(!badge.is_stale_for(Some("aaa")));
7948        assert!(badge.is_stale_for(Some("bbb")));
7949        // No local HEAD (unborn): nothing to compare against, so not stale.
7950        assert!(!badge.is_stale_for(None));
7951    }
7952
7953    #[test]
7954    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
7955        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
7956        // it — so asking would return exactly the cached verdict, and the badge
7957        // stays correctly stale via `is_stale_for` with no network. So a snapshot
7958        // that differs *only* in `head_sha` must compare **equal** as a watch.
7959        let snap = |sha: &str| {
7960            json!({"repos":[{
7961                "main_repo":"omni-dev",
7962                "github":{"owner":"rust-works","name":"omni-dev"},
7963                "root":"/r",
7964                "polling_enabled":true,
7965                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
7966            }]})
7967        };
7968        let before = pr_watch_from_snapshot(&snap("aaa"));
7969        let after = pr_watch_from_snapshot(&snap("bbb"));
7970        assert_eq!(before.len(), 1);
7971        assert_eq!(before[0].target, after[0].target);
7972        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
7973        assert_eq!(before, after);
7974        assert!(!pr_watch_grew(&before, &after));
7975    }
7976
7977    #[test]
7978    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
7979        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
7980        // it moves no local head — so an upstream move alone must register as "go
7981        // and ask now", or the badge sits at `●` until the backoff elapses.
7982        let snap = |upstream: &str| {
7983            json!({"repos":[{
7984                "main_repo":"omni-dev",
7985                "github":{"owner":"rust-works","name":"omni-dev"},
7986                "root":"/r",
7987                "polling_enabled":true,
7988                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
7989                              "upstream_sha":upstream,"is_main":true,"open":true}]
7990            }]})
7991        };
7992        let before = pr_watch_from_snapshot(&snap("aaa"));
7993        let after = pr_watch_from_snapshot(&snap("bbb"));
7994        // Same target, only the upstream moved — a genuine "grew" signal.
7995        assert_eq!(before.len(), 1);
7996        assert_eq!(before[0].target, after[0].target);
7997        assert_ne!(before, after);
7998        assert!(pr_watch_grew(&before, &after));
7999        // A quiet tick still asks nothing.
8000        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8001        assert!(!pr_watch_grew(
8002            &before,
8003            &pr_watch_from_snapshot(&snap("aaa"))
8004        ));
8005    }
8006
8007    #[test]
8008    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8009        // An older daemon — or any branch tracking nothing — simply sends no
8010        // `upstream_sha`, which reads as `None` rather than failing the poll.
8011        let snap = json!({"repos":[{
8012            "main_repo":"omni-dev",
8013            "github":{"owner":"rust-works","name":"omni-dev"},
8014            "root":"/r",
8015            "polling_enabled":true,
8016            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8017        }]});
8018        let watch = pr_watch_from_snapshot(&snap);
8019        assert_eq!(watch.len(), 1);
8020        assert_eq!(watch[0].upstream_sha, None);
8021    }
8022
8023    #[test]
8024    fn start_pr_poller_is_a_noop_outside_a_runtime() {
8025        let svc = WorktreesService::new();
8026        svc.start_pr_poller();
8027        assert!(svc
8028            .poller
8029            .lock()
8030            .unwrap_or_else(PoisonError::into_inner)
8031            .is_none());
8032    }
8033
8034    #[tokio::test]
8035    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8036        let svc = WorktreesService::new();
8037        svc.start_pr_poller_with(
8038            Duration::from_millis(50),
8039            Duration::from_millis(10),
8040            PathBuf::from("/bin/true"),
8041        );
8042        let token = svc
8043            .poller
8044            .lock()
8045            .unwrap_or_else(PoisonError::into_inner)
8046            .as_ref()
8047            .map(|t| t.token.clone())
8048            .expect("poller started");
8049
8050        // Cancel the live task, then start again: if `start` spawned a replacement
8051        // it would orphan this one, so the token staying cancelled proves it did not.
8052        token.cancel();
8053        svc.start_pr_poller_with(
8054            Duration::from_millis(50),
8055            Duration::from_millis(10),
8056            PathBuf::from("/bin/true"),
8057        );
8058        assert!(svc
8059            .poller
8060            .lock()
8061            .unwrap_or_else(PoisonError::into_inner)
8062            .as_ref()
8063            .is_some_and(|t| t.token.is_cancelled()));
8064
8065        svc.shutdown().await;
8066        assert!(svc
8067            .poller
8068            .lock()
8069            .unwrap_or_else(PoisonError::into_inner)
8070            .is_none());
8071    }
8072
8073    // --- Rate-limit monitor (#1375) ---
8074
8075    /// A resource at `used`% of a 100-request budget.
8076    fn rl_resource(used: u64) -> RateLimitResource {
8077        RateLimitResource {
8078            used,
8079            limit: 100,
8080            remaining: 100 - used,
8081            percent: used as f64,
8082            reset: 0,
8083        }
8084    }
8085
8086    #[test]
8087    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8088        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8089            graphql: Some(rl_resource(graphql)),
8090            core: Some(rl_resource(core)),
8091            search: None,
8092        };
8093        // First poll already over threshold → warn.
8094        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8095        // First poll below → no warn.
8096        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8097        // Crossing upward → warn.
8098        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8099        // Staying over → no repeat warn.
8100        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8101        // Recovering below → no warn.
8102        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8103        // A *different* resource crossing while the first recovers is still caught.
8104        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8105    }
8106
8107    #[test]
8108    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8109        let svc = WorktreesService::new();
8110        svc.start_rate_limit_poller();
8111        assert!(svc
8112            .rate_limit_poller
8113            .lock()
8114            .unwrap_or_else(PoisonError::into_inner)
8115            .is_none());
8116    }
8117
8118    #[tokio::test]
8119    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8120        let svc = WorktreesService::new();
8121        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8122        let token = svc
8123            .rate_limit_poller
8124            .lock()
8125            .unwrap_or_else(PoisonError::into_inner)
8126            .as_ref()
8127            .map(|t| t.token.clone())
8128            .expect("poller started");
8129
8130        // Cancel the live task, then start again: a second start must not spawn a
8131        // replacement (which would orphan this one), so the token stays cancelled.
8132        token.cancel();
8133        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8134        assert!(svc
8135            .rate_limit_poller
8136            .lock()
8137            .unwrap_or_else(PoisonError::into_inner)
8138            .as_ref()
8139            .is_some_and(|t| t.token.is_cancelled()));
8140
8141        svc.shutdown().await;
8142        assert!(svc
8143            .rate_limit_poller
8144            .lock()
8145            .unwrap_or_else(PoisonError::into_inner)
8146            .is_none());
8147    }
8148
8149    #[tokio::test]
8150    // Holds the shim guard across awaits; see the note above.
8151    #[allow(clippy::await_holding_lock)]
8152    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8153        let bin_dir = tempfile::tempdir().unwrap();
8154        let (fake, _shim) = fake_gh(
8155            bin_dir.path(),
8156            r#"{"resources":{
8157                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
8158                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
8159            }}"#,
8160        );
8161        let svc = WorktreesService::new();
8162        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
8163        // is being watched — a lease makes it active without needing a window/folder.
8164        svc.registry.set_polling("rust-works", "omni-dev", true);
8165        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
8166
8167        // Each poll spawns a real subprocess; wait on a generous deadline so a
8168        // loaded machine fails honestly rather than flaking.
8169        let snap = tokio::time::timeout(Duration::from_secs(30), async {
8170            loop {
8171                if let Some(snap) = svc.rate_limit_cache.get() {
8172                    return snap;
8173                }
8174                tokio::time::sleep(Duration::from_millis(25)).await;
8175            }
8176        })
8177        .await
8178        .expect("poller should populate the cache through the fake gh");
8179        assert_eq!(snap.graphql.unwrap().used, 4100);
8180        assert_eq!(snap.core.unwrap().used, 27);
8181
8182        // The reading reaches the built-in status field via the shared cache.
8183        assert!(svc.rate_limit_cache().get().is_some());
8184
8185        svc.shutdown().await;
8186        assert!(svc
8187            .rate_limit_poller
8188            .lock()
8189            .unwrap_or_else(PoisonError::into_inner)
8190            .is_none());
8191    }
8192
8193    #[tokio::test]
8194    // Holds the shim guard across awaits; see the note above.
8195    #[allow(clippy::await_holding_lock)]
8196    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
8197        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
8198        // `/rate_limit` subprocess — the counting stub records zero spawns.
8199        let bin_dir = tempfile::tempdir().unwrap();
8200        let (fake, _shim, counter) = counting_fake_gh(
8201            bin_dir.path(),
8202            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
8203        );
8204        let svc = WorktreesService::new();
8205        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
8206
8207        // Give the loop several ticks; with nothing registered it must never poll.
8208        tokio::time::sleep(Duration::from_millis(300)).await;
8209        assert_eq!(
8210            gh_spawn_count(&counter),
8211            0,
8212            "idle daemon must not poll (#1389, fix 8b)"
8213        );
8214        assert!(svc.rate_limit_cache.get().is_none());
8215
8216        // Once a lease is active, the next tick populates the cache.
8217        svc.registry.set_polling("rust-works", "omni-dev", true);
8218        tokio::time::timeout(Duration::from_secs(30), async {
8219            loop {
8220                if svc.rate_limit_cache.get().is_some() {
8221                    return;
8222                }
8223                tokio::time::sleep(Duration::from_millis(25)).await;
8224            }
8225        })
8226        .await
8227        .expect("an active lease should resume polling");
8228        assert!(gh_spawn_count(&counter) >= 1);
8229        svc.shutdown().await;
8230    }
8231
8232    #[tokio::test]
8233    async fn rate_limit_poller_survives_a_failing_gh() {
8234        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
8235        // the degraded branch keeps the last (here, absent) reading rather than
8236        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
8237        let svc = WorktreesService::new();
8238        svc.registry.set_polling("rust-works", "omni-dev", true);
8239        svc.start_rate_limit_poller_with(
8240            Duration::from_millis(20),
8241            PathBuf::from("/no/such/gh/xyzzy"),
8242        );
8243        // Let it fail a few times: the cache stays empty and the task stays alive.
8244        tokio::time::sleep(Duration::from_millis(150)).await;
8245        assert!(svc.rate_limit_cache.get().is_none());
8246        assert!(
8247            svc.rate_limit_poller
8248                .lock()
8249                .unwrap_or_else(PoisonError::into_inner)
8250                .is_some(),
8251            "the loop must survive a failing gh, not panic out"
8252        );
8253        svc.shutdown().await;
8254    }
8255
8256    #[test]
8257    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
8258        let svc = WorktreesService::new();
8259        // Empty cache → no rate-limit line (the pre-#1375 shape).
8260        let items = svc.menu().items;
8261        assert!(
8262            !items
8263                .iter()
8264                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
8265            "no github line before the first poll"
8266        );
8267
8268        // Populate the cache → the first item is the rate-limit status line.
8269        svc.rate_limit_cache.replace(RateLimitSnapshot {
8270            graphql: Some(rl_resource(82)),
8271            core: Some(rl_resource(3)),
8272            search: None,
8273        });
8274        let items = svc.menu().items;
8275        assert!(
8276            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
8277            "expected the github line first, got {items:?}"
8278        );
8279        assert!(
8280            matches!(items.get(1), Some(MenuItem::Separator)),
8281            "expected a separator after the github line"
8282        );
8283    }
8284
8285    #[tokio::test]
8286    // Holds the shim guard across awaits; see the note above.
8287    #[allow(clippy::await_holding_lock)]
8288    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8289        let dir = tempfile::tempdir().unwrap();
8290        github_repo(dir.path());
8291        let bin_dir = tempfile::tempdir().unwrap();
8292        // One repo, one branch → aliases r0/b0. A still-running check so the badge
8293        // stays pending and the loop keeps its fast cadence.
8294        let (fake, _shim) = fake_gh(
8295            bin_dir.path(),
8296            r#"{"data":{"r0":{"b0":{
8297                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8298                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8299                ]}}},
8300                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8301            }}}}"#,
8302        );
8303        let svc = WorktreesService::new();
8304        svc.handle(
8305            "register",
8306            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8307        )
8308        .await
8309        .unwrap();
8310        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8311        // act on it, as if the user had toggled it on.
8312        svc.registry.set_polling("rust-works", "omni-dev", true);
8313        svc.start_pr_poller_with(
8314            Duration::from_millis(50),
8315            Duration::from_millis(10),
8316            fake.clone(),
8317        );
8318
8319        // Wait on a generous wall-clock deadline: each poll spawns a real
8320        // subprocess, and under a loaded machine (a full `build.sh` runs a build
8321        // and clippy alongside) a tight budget flakes rather than fails honestly.
8322        let badge = tokio::time::timeout(Duration::from_secs(30), async {
8323            loop {
8324                if let Some(PrResolution::Pr(badge)) =
8325                    svc.pr_cache.get("rust-works", "omni-dev", "main")
8326                {
8327                    return badge;
8328                }
8329                tokio::time::sleep(Duration::from_millis(25)).await;
8330            }
8331        })
8332        .await
8333        .expect("poller should resolve a badge through the fake gh");
8334        assert_eq!(badge.number, 1337);
8335        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
8336
8337        // The badge reaches the wire the windows actually read.
8338        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8339        assert_eq!(wt["pr"]["number"], json!(1337));
8340
8341        // And the loop is quiescent after shutdown: the generation must stop moving.
8342        svc.shutdown().await;
8343        let generation = svc.registry.change_generation();
8344        tokio::time::sleep(Duration::from_millis(120)).await;
8345        assert_eq!(
8346            svc.registry.change_generation(),
8347            generation,
8348            "no bumps after shutdown"
8349        );
8350    }
8351
8352    #[tokio::test]
8353    // Holds the shim guard across awaits; see the note above.
8354    #[allow(clippy::await_holding_lock)]
8355    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
8356        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
8357        // the poller folds into the shared cache — so the graphql figure stays fresh
8358        // without a standalone `/rate_limit` call.
8359        let dir = tempfile::tempdir().unwrap();
8360        github_repo(dir.path());
8361        let bin_dir = tempfile::tempdir().unwrap();
8362        let (fake, _shim) = fake_gh(
8363            bin_dir.path(),
8364            r#"{"data":{
8365                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
8366                             "resetAt":"2026-07-21T16:00:00Z"},
8367                "r0":{"b0":{
8368                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8369                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8370                  ]}}},
8371                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8372                }}
8373            }}"#,
8374        );
8375        let svc = WorktreesService::new();
8376        svc.handle(
8377            "register",
8378            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8379        )
8380        .await
8381        .unwrap();
8382        svc.registry.set_polling("rust-works", "omni-dev", true);
8383        // No rate-limit poller started: the only writer of the cache is the PR poll's
8384        // folded-in budget, so a populated graphql reading proves fix 8a.
8385        svc.start_pr_poller_with(
8386            Duration::from_millis(50),
8387            Duration::from_millis(10),
8388            fake.clone(),
8389        );
8390
8391        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
8392            loop {
8393                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
8394                    return g;
8395                }
8396                tokio::time::sleep(Duration::from_millis(25)).await;
8397            }
8398        })
8399        .await
8400        .expect("the PR poll should fold its budget into the cache");
8401        assert_eq!(graphql.used, 123);
8402        assert_eq!(graphql.limit, 5000);
8403        assert_eq!(graphql.remaining, 4877);
8404        svc.shutdown().await;
8405    }
8406
8407    #[tokio::test]
8408    // Holds the shim guard across awaits; see the note above.
8409    #[allow(clippy::await_holding_lock)]
8410    async fn pr_poll_counts_every_gh_call_exactly_once() {
8411        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
8412        // still counted. Compares the ground-truth number of `gh` subprocesses the
8413        // poll actually spawned against the number of successful `kind:"gh"` records
8414        // the counted `run_gh` choke point wrote — they must be equal, so a future
8415        // refactor cannot add an uncounted `gh` path (counted < spawns) without
8416        // failing here.
8417        let dir = tempfile::tempdir().unwrap();
8418        github_repo(dir.path());
8419        let bin_dir = tempfile::tempdir().unwrap();
8420        let (fake, _shim, counter) = counting_fake_gh(
8421            bin_dir.path(),
8422            r#"{"data":{"r0":{"b0":{
8423                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8424                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8425                ]}}},
8426                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
8427            }}}}"#,
8428        );
8429        let log = bin_dir.path().join("log.jsonl");
8430        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
8431
8432        let svc = WorktreesService::new();
8433        svc.handle(
8434            "register",
8435            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8436        )
8437        .await
8438        .unwrap();
8439        svc.registry.set_polling("rust-works", "omni-dev", true);
8440        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
8441
8442        // Wait for at least one fetch to land.
8443        tokio::time::timeout(Duration::from_secs(30), async {
8444            loop {
8445                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8446                    return;
8447                }
8448                tokio::time::sleep(Duration::from_millis(25)).await;
8449            }
8450        })
8451        .await
8452        .expect("poller should fetch through the fake gh");
8453
8454        // Stop the loop so both counts are final (no in-flight gh), then compare.
8455        svc.shutdown().await;
8456        let spawns = gh_spawn_count(&counter);
8457        let counted = counted_gh_records(&log);
8458        std::env::remove_var("OMNI_DEV_LOG_FILE");
8459        assert!(
8460            spawns >= 1,
8461            "the poll should have spent at least one gh call"
8462        );
8463        assert_eq!(
8464            counted, spawns,
8465            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
8466        );
8467    }
8468
8469    #[tokio::test]
8470    // Holds the shim guard across awaits; see the note above.
8471    #[allow(clippy::await_holding_lock)]
8472    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
8473        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
8474        // its windows) that each *grow* the watch must collapse into ONE fetch on
8475        // the final set, not one per window. Two distinct repos appear back-to-back
8476        // inside the debounce window; a debounce-free loop would fetch twice.
8477        let dir_a = tempfile::tempdir().unwrap();
8478        let dir_b = tempfile::tempdir().unwrap();
8479        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
8480        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
8481        let bin_dir = tempfile::tempdir().unwrap();
8482        // Both terminal, so no fast pending cadence can add a second fetch.
8483        let (fake, _shim, counter) = counting_fake_gh(
8484            bin_dir.path(),
8485            r#"{"data":{
8486                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8487                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8488                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
8489                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
8490                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8491                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
8492            }}"#,
8493        );
8494        let svc = WorktreesService::new();
8495        // Enable polling for both before they register, so the first snapshot after
8496        // the storm already counts them.
8497        svc.registry.set_polling("rust-works", "omni-dev", true);
8498        svc.registry.set_polling("rust-works", "other-repo", true);
8499        // `base` far larger than the test so only the storm — never the cadence —
8500        // can trigger a fetch; a generous debounce so the two registers land inside
8501        // one settle window even on a loaded machine.
8502        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
8503        // The burst: both windows register back-to-back.
8504        svc.handle(
8505            "register",
8506            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
8507        )
8508        .await
8509        .unwrap();
8510        // A beat between the two, so the first bump has (all but certainly)
8511        // woken the poller into its settle window before the second arrives —
8512        // exercising the debounce *restart*, not just a single coalesced wake.
8513        tokio::time::sleep(Duration::from_millis(50)).await;
8514        svc.handle(
8515            "register",
8516            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
8517        )
8518        .await
8519        .unwrap();
8520
8521        // Wait until both badges resolve — proving the single fetch covered the full
8522        // final set, not just the first window.
8523        tokio::time::timeout(Duration::from_secs(30), async {
8524            loop {
8525                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
8526                let b = svc
8527                    .pr_cache
8528                    .get("rust-works", "other-repo", "main")
8529                    .is_some();
8530                if a && b {
8531                    return;
8532                }
8533                tokio::time::sleep(Duration::from_millis(25)).await;
8534            }
8535        })
8536        .await
8537        .expect("the debounced fetch should resolve both repos");
8538
8539        svc.shutdown().await;
8540        assert_eq!(
8541            gh_spawn_count(&counter),
8542            1,
8543            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
8544        );
8545    }
8546
8547    #[tokio::test]
8548    // Holds the shim guard across awaits; see the note above.
8549    #[allow(clippy::await_holding_lock)]
8550    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
8551        // The settle loop is bounded: a drip of registry bumps, each landing
8552        // inside the debounce window, must not postpone the poll forever — the
8553        // overall deadline (4× the debounce) forces the snapshot mid-storm
8554        // (#1389, fix 2).
8555        let dir = tempfile::tempdir().unwrap();
8556        github_repo(dir.path());
8557        let bin_dir = tempfile::tempdir().unwrap();
8558        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8559        let svc = WorktreesService::new();
8560        svc.registry.set_polling("rust-works", "omni-dev", true);
8561        // `base` far past the timeout below, so only the grew-trigger — released by
8562        // the deadline — can fetch; a base the drip could outlive would let the
8563        // periodic cadence satisfy the assertion on a very slow run (#1426).
8564        svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
8565        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
8566        svc.handle("register", register.clone()).await.unwrap();
8567        // Re-register (an upsert, but still a bump) every 25ms — inside the 50ms
8568        // debounce — until the deadline-forced fetch actually lands. The observation
8569        // point is the condition itself, never a clock: sampling the counter at a
8570        // fixed instant would additionally assert that a `fork` + `exec` + `/bin/sh`
8571        // startup + file append all beat that instant, which is what made this flaky
8572        // under full-suite load (#1426). Under load the drip just runs longer. Every
8573        // iteration bumps, so the fetch is still observed *while bumps are arriving*
8574        // — a deadline-free settle loop never fetches here at all, and the timeout
8575        // *is* the assertion.
8576        let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
8577            loop {
8578                tokio::time::sleep(Duration::from_millis(25)).await;
8579                svc.handle("register", register.clone()).await.unwrap();
8580                if gh_spawn_count(&counter) >= 1 {
8581                    return;
8582                }
8583            }
8584        })
8585        .await;
8586        svc.shutdown().await;
8587        forced_mid_drip.expect(
8588            "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
8589        );
8590    }
8591
8592    #[tokio::test]
8593    // Holds the shim guard across awaits; see the note above.
8594    #[allow(clippy::await_holding_lock)]
8595    async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
8596        // #1389, fix 4: a daemon restart within the backoff window serves badges
8597        // from the persisted cache and spends **no** gh call, because every current
8598        // target already has a fresh verdict.
8599        let dir = tempfile::tempdir().unwrap();
8600        let repo = github_repo(dir.path());
8601        let head = repo.head().unwrap().target().unwrap().to_string();
8602        let bin_dir = tempfile::tempdir().unwrap();
8603        // If the poller wrongly fetched, this empty reply would still spawn the stub
8604        // and bump the counter — which is exactly what the assertion catches.
8605        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8606
8607        // Persist a fresh cache the way the previous daemon would have: a badge for
8608        // `main` whose verdict is about the current head (so it is not stale),
8609        // watched at `(main, no upstream)`, resolved just now.
8610        let cache_path = bin_dir.path().join("pr-cache.json");
8611        let target = PrTarget {
8612            owner: "rust-works".into(),
8613            name: "omni-dev".into(),
8614            branch: "main".into(),
8615        };
8616        let prefs = pr_cache_prefs_from(
8617            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
8618            &[watch("main", None)],
8619            Utc::now(),
8620        );
8621        write_pr_cache(&cache_path, &prefs).unwrap();
8622
8623        let svc = WorktreesService::new();
8624        svc.load_pr_cache(cache_path);
8625        svc.registry.set_polling("rust-works", "omni-dev", true);
8626        svc.handle(
8627            "register",
8628            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8629        )
8630        .await
8631        .unwrap();
8632        // `base` far larger than the test: the only fetch that could happen is the
8633        // immediate one we expect the warm cache to skip.
8634        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8635
8636        // The restored badge renders on the wire without a gh call.
8637        let number = tokio::time::timeout(Duration::from_secs(30), async {
8638            loop {
8639                let tree = svc.handle("tree", Value::Null).await.unwrap();
8640                if let Some(n) = repos_of(&tree)
8641                    .first()
8642                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
8643                {
8644                    return n;
8645                }
8646                tokio::time::sleep(Duration::from_millis(25)).await;
8647            }
8648        })
8649        .await
8650        .expect("the restored badge should render from the warm cache");
8651        assert_eq!(number, 1337);
8652
8653        // Let the poller run a while, then confirm it stayed quiet.
8654        tokio::time::sleep(Duration::from_millis(300)).await;
8655        svc.shutdown().await;
8656        assert_eq!(
8657            gh_spawn_count(&counter),
8658            0,
8659            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
8660        );
8661    }
8662
8663    #[tokio::test]
8664    // Holds the shim guard across awaits; see the note above.
8665    #[allow(clippy::await_holding_lock)]
8666    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
8667        // #1389, fix 4, write side (the twin of the skip test above, which reads
8668        // a hand-written file): a successful resolve persists the cache —
8669        // creating the runtime dir if needed — so the *next* daemon restart
8670        // warm-starts from it.
8671        let dir = tempfile::tempdir().unwrap();
8672        github_repo(dir.path());
8673        let bin_dir = tempfile::tempdir().unwrap();
8674        let (fake, _shim) = fake_gh(
8675            bin_dir.path(),
8676            r#"{"data":{"r0":{"b0":{
8677                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8678                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
8679                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
8680            }}}}"#,
8681        );
8682        let svc = WorktreesService::new();
8683        // No file yet, and no parent dir either: the load takes the benign
8684        // NotFound arm, and the write must create the `0700` runtime dir.
8685        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
8686        svc.load_pr_cache(cache_path.clone());
8687        svc.registry.set_polling("rust-works", "omni-dev", true);
8688        svc.handle(
8689            "register",
8690            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8691        )
8692        .await
8693        .unwrap();
8694        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8695
8696        // A partially-written or not-yet-written file simply retries: only a
8697        // fully parseable cache ends the wait.
8698        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
8699            loop {
8700                if let Ok(bytes) = std::fs::read(&cache_path) {
8701                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
8702                        if !prefs.entries.is_empty() {
8703                            return prefs;
8704                        }
8705                    }
8706                }
8707                tokio::time::sleep(Duration::from_millis(25)).await;
8708            }
8709        })
8710        .await
8711        .expect("a successful resolve should persist the cache file");
8712        svc.shutdown().await;
8713
8714        assert_eq!(prefs.entries[0].target.branch, "main");
8715        assert!(
8716            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
8717            "{:?}",
8718            prefs.entries[0].resolution
8719        );
8720        assert_eq!(
8721            prefs.watched,
8722            vec![PersistedWatch {
8723                target: prefs.entries[0].target.clone(),
8724                upstream_sha: None
8725            }]
8726        );
8727        assert!(
8728            prefs.polled_at.is_some(),
8729            "the poll time is what ages the next warm start"
8730        );
8731    }
8732
8733    #[tokio::test]
8734    // Holds the shim guard across awaits; see the note above.
8735    #[allow(clippy::await_holding_lock)]
8736    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
8737        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
8738        // to one counted `gh pr list` per repo. A generous TTL, so the second call
8739        // is served from the cache and spawns **no** second `gh` — the whole point.
8740        let bin_dir = tempfile::tempdir().unwrap();
8741        let (fake, _shim, counter) = counting_fake_gh(
8742            bin_dir.path(),
8743            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
8744                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
8745        );
8746        let svc = WorktreesService::new();
8747
8748        let prs = svc
8749            .open_prs_with("rust-works", "omni-dev", fake.clone())
8750            .await
8751            .expect("gh pr list should resolve");
8752        assert_eq!(prs.len(), 1);
8753        assert_eq!(prs[0]["number"], json!(42));
8754        assert_eq!(prs[0]["url"], json!("http://x/42"));
8755        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
8756
8757        // A second window asking the same repo is served from the shared cache.
8758        let again = svc
8759            .open_prs_with("rust-works", "omni-dev", fake.clone())
8760            .await
8761            .expect("cache hit should resolve");
8762        assert_eq!(again, prs);
8763        assert_eq!(
8764            gh_spawn_count(&counter),
8765            1,
8766            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
8767        );
8768
8769        // The op wrapper shapes the reply and validates the payload.
8770        let reply = svc
8771            .handle(
8772                "open-prs",
8773                json!({ "owner": "rust-works", "name": "omni-dev" }),
8774            )
8775            .await
8776            .expect("open-prs op should route");
8777        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
8778        assert!(svc
8779            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
8780            .await
8781            .is_err());
8782    }
8783
8784    #[test]
8785    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
8786        // The three degraded shapes a real `gh` presents — not installed, a
8787        // nonzero exit (auth/network), and output that is not the JSON array
8788        // the menu indexes into — must each be a distinct, actionable error
8789        // rather than a panic or a silently empty list (#1389, fix 7).
8790        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
8791        assert!(
8792            err.to_string().contains("is the GitHub CLI installed"),
8793            "{err:#}"
8794        );
8795
8796        let bin_dir = tempfile::tempdir().unwrap();
8797        let _guard = shim_lock();
8798        let failing = bin_dir.path().join("fake-gh-fails");
8799        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
8800        let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
8801        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
8802        assert!(err.to_string().contains("boom"), "{err:#}");
8803
8804        let object = bin_dir.path().join("fake-gh-object");
8805        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
8806        let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
8807        assert!(
8808            err.to_string().contains("did not return a JSON array"),
8809            "{err:#}"
8810        );
8811    }
8812
8813    #[tokio::test]
8814    // Holds the shim guard across awaits; see the note above.
8815    #[allow(clippy::await_holding_lock)]
8816    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
8817        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
8818        // cadence and ignores even a grown watch, so no runaway can drain the shared
8819        // budget. A recent warm `last_poll` is seeded so the "first sight always
8820        // fetches" base case cannot mask the throttle — the only thing that could
8821        // fetch here is the grew-trigger, which the throttle suppresses.
8822        let dir = tempfile::tempdir().unwrap();
8823        github_repo(dir.path());
8824        let bin_dir = tempfile::tempdir().unwrap();
8825        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
8826
8827        let svc = WorktreesService::new();
8828        // Warm start with an *empty* watch but a fresh poll time: the registered
8829        // repo then reads as a grown watch, while `last_poll` is recent enough that
8830        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
8831        *svc.pr_warm_start
8832            .lock()
8833            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
8834            watched: vec![],
8835            polled_at: Utc::now(),
8836        });
8837        // Budget over the warn threshold before the poller starts.
8838        svc.rate_limit_cache.replace(RateLimitSnapshot {
8839            graphql: Some(rl_resource(90)),
8840            core: Some(rl_resource(3)),
8841            search: None,
8842        });
8843        svc.registry.set_polling("rust-works", "omni-dev", true);
8844        svc.handle(
8845            "register",
8846            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8847        )
8848        .await
8849        .unwrap();
8850        // `base` far larger than the test, so a fetch could only come from the
8851        // grew-trigger the throttle is meant to suppress.
8852        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
8853
8854        // Let the poller wake on the registration and run a while.
8855        tokio::time::sleep(Duration::from_millis(300)).await;
8856        svc.shutdown().await;
8857        assert_eq!(
8858            gh_spawn_count(&counter),
8859            0,
8860            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
8861        );
8862    }
8863
8864    #[tokio::test]
8865    // Holds the shim guard across awaits; see the note above.
8866    #[allow(clippy::await_holding_lock)]
8867    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
8868        // The diff-and-drop contract: an unchanged poll must not bump, or every
8869        // window re-renders on every tick — the cost this design exists to avoid.
8870        let dir = tempfile::tempdir().unwrap();
8871        github_repo(dir.path());
8872        let bin_dir = tempfile::tempdir().unwrap();
8873        let (fake, _shim) = fake_gh(
8874            bin_dir.path(),
8875            r#"{"data":{"r0":{"b0":{
8876                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
8877                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8878                ]}}},
8879                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
8880            }}}}"#,
8881        );
8882        let svc = WorktreesService::new();
8883        svc.handle(
8884            "register",
8885            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8886        )
8887        .await
8888        .unwrap();
8889        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8890        // act on it, as if the user had toggled it on.
8891        svc.registry.set_polling("rust-works", "omni-dev", true);
8892        svc.start_pr_poller_with(
8893            Duration::from_millis(50),
8894            Duration::from_millis(10),
8895            fake.clone(),
8896        );
8897
8898        tokio::time::timeout(Duration::from_secs(30), async {
8899            loop {
8900                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
8901                    return;
8902                }
8903                tokio::time::sleep(Duration::from_millis(25)).await;
8904            }
8905        })
8906        .await
8907        .expect("poller should resolve a badge through the fake gh");
8908        // The fake always answers identically, so after the first resolve every
8909        // subsequent poll is a no-change and must leave the generation alone.
8910        let settled = svc.registry.change_generation();
8911        tokio::time::sleep(Duration::from_millis(150)).await;
8912        assert_eq!(
8913            svc.registry.change_generation(),
8914            settled,
8915            "an unchanged poll must not bump the change-notify"
8916        );
8917        svc.shutdown().await;
8918    }
8919
8920    #[tokio::test]
8921    // Holds the shim guard across awaits; see the note above.
8922    #[allow(clippy::await_holding_lock)]
8923    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
8924        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
8925        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
8926        // as `pr_none`, and — since the answer never changes — bumps the
8927        // change-notify only for the poll that first delivered it.
8928        let dir = tempfile::tempdir().unwrap();
8929        github_repo(dir.path());
8930        let bin_dir = tempfile::tempdir().unwrap();
8931        let (fake, _shim) = fake_gh(
8932            bin_dir.path(),
8933            r#"{"data":{"r0":{"b0":{
8934                "target":{"oid":"abc","statusCheckRollup":null},
8935                "associatedPullRequests":{"nodes":[]}
8936            }}}}"#,
8937        );
8938        let svc = WorktreesService::new();
8939        svc.handle(
8940            "register",
8941            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8942        )
8943        .await
8944        .unwrap();
8945        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8946        // act on it, as if the user had toggled it on.
8947        svc.registry.set_polling("rust-works", "omni-dev", true);
8948        svc.start_pr_poller_with(
8949            Duration::from_millis(50),
8950            Duration::from_millis(10),
8951            fake.clone(),
8952        );
8953
8954        tokio::time::timeout(Duration::from_secs(30), async {
8955            loop {
8956                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
8957                    return;
8958                }
8959                tokio::time::sleep(Duration::from_millis(25)).await;
8960            }
8961        })
8962        .await
8963        .expect("poller should resolve the negative through the fake gh");
8964
8965        // The negative reaches the wire the windows actually read.
8966        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8967        assert_eq!(wt["pr_none"], json!(true));
8968        assert!(wt.get("pr").is_none(), "{wt:?}");
8969
8970        // Identical re-polls of the same negative must not bump.
8971        let settled = svc.registry.change_generation();
8972        tokio::time::sleep(Duration::from_millis(150)).await;
8973        assert_eq!(
8974            svc.registry.change_generation(),
8975            settled,
8976            "an unchanged negative must not bump the change-notify"
8977        );
8978        svc.shutdown().await;
8979    }
8980
8981    #[test]
8982    fn sync_indicator_formats_only_with_upstream() {
8983        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
8984        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
8985        assert_eq!(sync_indicator(None, None), None);
8986        // A partial pair (no real upstream) yields nothing.
8987        assert_eq!(sync_indicator(Some(1), None), None);
8988    }
8989
8990    #[tokio::test]
8991    async fn list_enriches_entries_with_git_status() {
8992        let dir = tempfile::tempdir().unwrap();
8993        let repo = init_repo(dir.path());
8994        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
8995        repo.set_head("refs/heads/main").unwrap();
8996
8997        let svc = WorktreesService::new();
8998        svc.handle(
8999            "register",
9000            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9001        )
9002        .await
9003        .unwrap();
9004        let payload = svc.handle("list", Value::Null).await.unwrap();
9005        let windows = windows_of(&payload);
9006        assert_eq!(windows.len(), 1);
9007        assert_eq!(
9008            windows[0].get("branch").and_then(Value::as_str),
9009            Some("main")
9010        );
9011        // No upstream configured → the ahead/behind keys are absent, not zero.
9012        assert!(windows[0].get("ahead").is_none());
9013        assert!(windows[0].get("behind").is_none());
9014        // The main repo name is enriched onto the entry.
9015        assert_eq!(
9016            windows[0].get("main_repo").and_then(Value::as_str),
9017            dir.path().file_name().and_then(|n| n.to_str())
9018        );
9019
9020        // A non-repo folder is still listed, just without a branch or main repo.
9021        let plain = tempfile::tempdir().unwrap();
9022        svc.handle(
9023            "register",
9024            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9025        )
9026        .await
9027        .unwrap();
9028        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9029        let w2 = windows
9030            .iter()
9031            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9032            .unwrap();
9033        assert!(w2.get("branch").is_none());
9034        assert!(w2.get("main_repo").is_none());
9035    }
9036
9037    #[test]
9038    fn window_label_prefers_git_branch_over_title() {
9039        let dir = tempfile::tempdir().unwrap();
9040        let repo = init_repo(dir.path());
9041        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9042        repo.set_head("refs/heads/main").unwrap();
9043        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9044        let entry = WindowEntry {
9045            key: "k".to_string(),
9046            folders: vec![dir.path().to_path_buf()],
9047            // Both the companion `repo` and `title` are overridden by the
9048            // git-derived main repo name and computed branch.
9049            repo: Some("companion-repo".to_string()),
9050            title: Some("ignored title".to_string()),
9051            pid: None,
9052            last_seen: Utc::now(),
9053        };
9054        // Main checkout: `repo · branch`, and with no upstream there is no sync.
9055        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9056    }
9057
9058    #[tokio::test]
9059    async fn list_includes_ahead_behind_for_tracking_branch() {
9060        let dir = tempfile::tempdir().unwrap();
9061        let _repo = diverging_repo(dir.path());
9062
9063        let svc = WorktreesService::new();
9064        svc.handle(
9065            "register",
9066            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9067        )
9068        .await
9069        .unwrap();
9070        let payload = svc.handle("list", Value::Null).await.unwrap();
9071        let windows = windows_of(&payload);
9072        // A tracking branch serializes branch plus both divergence counts.
9073        assert_eq!(
9074            windows[0].get("branch").and_then(Value::as_str),
9075            Some("main")
9076        );
9077        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9078        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9079    }
9080
9081    #[test]
9082    fn window_label_includes_sync_for_tracking_branch() {
9083        let dir = tempfile::tempdir().unwrap();
9084        let _repo = diverging_repo(dir.path());
9085        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9086        let entry = WindowEntry {
9087            key: "k".to_string(),
9088            folders: vec![dir.path().to_path_buf()],
9089            repo: Some("companion-repo".to_string()),
9090            title: None,
9091            pid: None,
9092            last_seen: Utc::now(),
9093        };
9094        // A tracking branch appends the `(+ahead -behind)` sync indicator.
9095        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9096    }
9097
9098    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
9099    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
9100    /// <wt_path>`.
9101    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9102        let commit = repo.find_commit(base).unwrap();
9103        repo.branch(branch, &commit, false).unwrap();
9104        let reference = repo
9105            .find_reference(&format!("refs/heads/{branch}"))
9106            .unwrap();
9107        let mut opts = git2::WorktreeAddOptions::new();
9108        opts.reference(Some(&reference));
9109        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9110    }
9111
9112    #[test]
9113    fn git_status_marks_linked_worktree_and_names_parent_repo() {
9114        let main_dir = tempfile::tempdir().unwrap();
9115        let repo = init_repo(main_dir.path());
9116        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9117        repo.set_head("refs/heads/main").unwrap();
9118
9119        // A linked worktree checked out on a new `feature` branch, in a
9120        // directory whose basename is deliberately *not* the repo name.
9121        let wt_parent = tempfile::tempdir().unwrap();
9122        let wt_path = wt_parent.path().join("feature-wt");
9123        add_worktree(&repo, a, &wt_path, "feature");
9124
9125        let status = git_status(&wt_path);
9126        assert!(status.is_worktree);
9127        assert_eq!(status.branch.as_deref(), Some("feature"));
9128        // The worktree names its *parent* repo, not its worktree-folder basename.
9129        assert_eq!(
9130            status.main_repo.as_deref(),
9131            main_dir.path().file_name().and_then(|n| n.to_str())
9132        );
9133
9134        // The main checkout resolves the same repo name and is not a worktree.
9135        let main_status = git_status(main_dir.path());
9136        assert!(!main_status.is_worktree);
9137        assert_eq!(main_status.main_repo, status.main_repo);
9138    }
9139
9140    #[test]
9141    fn window_label_marks_worktree_with_fork_glyph() {
9142        let main_dir = tempfile::tempdir().unwrap();
9143        let repo = init_repo(main_dir.path());
9144        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9145        repo.set_head("refs/heads/main").unwrap();
9146        let wt_parent = tempfile::tempdir().unwrap();
9147        let wt_path = wt_parent.path().join("feature-wt");
9148        add_worktree(&repo, a, &wt_path, "feature");
9149
9150        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9151        let entry = WindowEntry {
9152            key: "k".to_string(),
9153            folders: vec![wt_path],
9154            repo: Some("feature-wt".to_string()),
9155            title: None,
9156            pid: None,
9157            last_seen: Utc::now(),
9158        };
9159        // A worktree line: parent repo, the fork glyph, then the branch (no
9160        // upstream here, so no sync suffix).
9161        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
9162    }
9163
9164    #[test]
9165    fn main_repo_name_derives_from_common_dir() {
9166        // Normal layout: the repo is the directory that contains `.git`.
9167        assert_eq!(
9168            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
9169            Some("omni-dev")
9170        );
9171        // A trailing slash on the common dir does not change the answer.
9172        assert_eq!(
9173            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
9174            Some("omni-dev")
9175        );
9176        // A bare repo: its own directory name, without the `.git` suffix.
9177        assert_eq!(
9178            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
9179            Some("omni-dev")
9180        );
9181        // A `.git` at the filesystem root has no parent name to use.
9182        assert_eq!(main_repo_name(Path::new("/.git")), None);
9183    }
9184
9185    // --- Repo/worktree tree (#1265) ----------------------------------------
9186
9187    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
9188    /// temporary payload).
9189    fn repos_of(payload: &Value) -> Vec<Value> {
9190        payload
9191            .get("repos")
9192            .and_then(Value::as_array)
9193            .expect("repos array")
9194            .clone()
9195    }
9196
9197    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
9198        Some(GithubIdentity {
9199            owner: owner.to_string(),
9200            name: name.to_string(),
9201        })
9202    }
9203
9204    #[test]
9205    fn github_identity_parses_supported_forms() {
9206        // https / http, with and without the `.git` suffix.
9207        assert_eq!(
9208            github_identity("https://github.com/rust-works/omni-dev.git"),
9209            github("rust-works", "omni-dev")
9210        );
9211        assert_eq!(
9212            github_identity("https://github.com/rust-works/omni-dev"),
9213            github("rust-works", "omni-dev")
9214        );
9215        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
9216        // SCP-like and ssh:// / git:// forms.
9217        assert_eq!(
9218            github_identity("git@github.com:rust-works/omni-dev.git"),
9219            github("rust-works", "omni-dev")
9220        );
9221        assert_eq!(
9222            github_identity("ssh://git@github.com/o/r.git"),
9223            github("o", "r")
9224        );
9225        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
9226        // A trailing slash and surrounding whitespace are tolerated.
9227        assert_eq!(
9228            github_identity("  https://github.com/o/r/  "),
9229            github("o", "r")
9230        );
9231    }
9232
9233    #[test]
9234    fn github_identity_rejects_non_github_and_malformed() {
9235        // Non-GitHub hosts.
9236        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
9237        assert_eq!(github_identity("git@example.com:o/r.git"), None);
9238        // Missing or extra path segments.
9239        assert_eq!(github_identity("https://github.com/onlyowner"), None);
9240        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
9241        assert_eq!(github_identity("https://github.com/"), None);
9242        // Not a URL at all.
9243        assert_eq!(github_identity("not a url"), None);
9244    }
9245
9246    #[test]
9247    fn remote_github_identity_reads_origin_then_falls_back() {
9248        let dir = tempfile::tempdir().unwrap();
9249        let repo = init_repo(dir.path());
9250        // No remotes → None.
9251        assert_eq!(remote_github_identity(&repo), None);
9252        // A non-GitHub origin is not a match.
9253        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
9254        assert_eq!(remote_github_identity(&repo), None);
9255        // A GitHub origin resolves to its identity.
9256        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
9257            .unwrap();
9258        assert_eq!(
9259            remote_github_identity(&repo),
9260            github("rust-works", "omni-dev")
9261        );
9262
9263        // Origin non-GitHub but another remote is GitHub: the fallback loop over
9264        // the remaining remotes finds it.
9265        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
9266            .unwrap();
9267        repo.remote("upstream", "https://github.com/other/proj.git")
9268            .unwrap();
9269        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
9270    }
9271
9272    #[tokio::test]
9273    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
9274        let svc = WorktreesService::new();
9275        // No windows → an empty repo set (not an error), toggle at its default.
9276        assert_eq!(
9277            svc.handle("tree", Value::Null).await.unwrap(),
9278            json!({ "repos": [], "show_closed": true })
9279        );
9280        // A plain non-repo folder is skipped rather than sinking the op.
9281        let plain = tempfile::tempdir().unwrap();
9282        svc.handle(
9283            "register",
9284            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
9285        )
9286        .await
9287        .unwrap();
9288        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
9289    }
9290
9291    #[tokio::test]
9292    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
9293        let main_dir = tempfile::tempdir().unwrap();
9294        let repo = init_repo(main_dir.path());
9295        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9296        repo.set_head("refs/heads/main").unwrap();
9297        // A GitHub origin so the repo carries an identity in the payload.
9298        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
9299            .unwrap();
9300
9301        // A linked worktree on a new `feature` branch, in a directory whose
9302        // basename is deliberately not the repo name.
9303        let wt_parent = tempfile::tempdir().unwrap();
9304        let wt_path = wt_parent.path().join("feature-wt");
9305        add_worktree(&repo, a, &wt_path, "feature");
9306
9307        let svc = WorktreesService::new();
9308        // A window open on the main checkout and one on the linked worktree —
9309        // two windows, but one repo (they must dedupe).
9310        svc.handle(
9311            "register",
9312            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9313        )
9314        .await
9315        .unwrap();
9316        svc.handle(
9317            "register",
9318            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
9319        )
9320        .await
9321        .unwrap();
9322
9323        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9324        assert_eq!(
9325            repos.len(),
9326            1,
9327            "two worktrees of one repo dedupe: {repos:?}"
9328        );
9329        let repo0 = &repos[0];
9330        // Repo identity is the parent-repo name (not a worktree-folder basename).
9331        assert_eq!(
9332            repo0.get("main_repo").and_then(Value::as_str),
9333            main_dir.path().file_name().and_then(|n| n.to_str())
9334        );
9335        assert_eq!(
9336            repo0.pointer("/github/owner").and_then(Value::as_str),
9337            Some("rust-works")
9338        );
9339        assert_eq!(
9340            repo0.pointer("/github/name").and_then(Value::as_str),
9341            Some("omni-dev")
9342        );
9343        assert!(repo0.get("root").and_then(Value::as_str).is_some());
9344
9345        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
9346        assert_eq!(worktrees.len(), 2);
9347        // Main working tree first: is_main, open, with the main window's key.
9348        let main_wt = &worktrees[0];
9349        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
9350        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
9351        assert_eq!(
9352            main_wt.get("window_key").and_then(Value::as_str),
9353            Some("wm")
9354        );
9355        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
9356        // Linked worktree: not main, open via the feature window.
9357        let linked = &worktrees[1];
9358        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
9359        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
9360        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
9361        assert_eq!(
9362            linked.get("branch").and_then(Value::as_str),
9363            Some("feature")
9364        );
9365    }
9366
9367    #[tokio::test]
9368    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
9369        let main_dir = tempfile::tempdir().unwrap();
9370        let repo = init_repo(main_dir.path());
9371        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9372        repo.set_head("refs/heads/main").unwrap();
9373        // No remote at all → the repo carries no `github` identity.
9374        let wt_parent = tempfile::tempdir().unwrap();
9375        let wt_path = wt_parent.path().join("feature-wt");
9376        add_worktree(&repo, a, &wt_path, "feature");
9377
9378        let svc = WorktreesService::new();
9379        // Only the main checkout has a window; the linked worktree has none.
9380        svc.handle(
9381            "register",
9382            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
9383        )
9384        .await
9385        .unwrap();
9386
9387        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
9388        assert_eq!(repos.len(), 1);
9389        assert!(repos[0].get("github").is_none(), "no remote → no github");
9390        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
9391        let linked = worktrees
9392            .iter()
9393            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
9394            .expect("the linked worktree");
9395        // Enumerated even though no window has it open, and marked closed.
9396        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
9397        assert!(linked.get("window_key").is_none());
9398    }
9399
9400    // --- Close op (#1277) --------------------------------------------------
9401
9402    /// Builds a repo whose main working tree is on `trunk` with one **clean**
9403    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
9404    /// paths stay valid) and the linked worktree path.
9405    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
9406        let main_dir = tempfile::tempdir().unwrap();
9407        let repo = init_repo(main_dir.path());
9408        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9409        repo.set_head("refs/heads/trunk").unwrap();
9410        let wt_parent = tempfile::tempdir().unwrap();
9411        let wt_path = wt_parent.path().join("feature-wt");
9412        add_worktree(&repo, a, &wt_path, "feature");
9413        (main_dir, wt_parent, wt_path)
9414    }
9415
9416    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
9417    /// repo — the shape a multi-select delete fans out over, and the only one where
9418    /// two prunes share a `.git/worktrees` to race on (#1359).
9419    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
9420    {
9421        let main_dir = tempfile::tempdir().unwrap();
9422        let repo = init_repo(main_dir.path());
9423        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
9424        repo.set_head("refs/heads/trunk").unwrap();
9425        let wt_parent = tempfile::tempdir().unwrap();
9426        let first = wt_parent.path().join("first-wt");
9427        let second = wt_parent.path().join("second-wt");
9428        add_worktree(&repo, a, &first, "first");
9429        add_worktree(&repo, a, &second, "second");
9430        (main_dir, wt_parent, first, second)
9431    }
9432
9433    #[tokio::test]
9434    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
9435        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
9436        let svc = Arc::new(WorktreesService::new());
9437
9438        // The multi-select fan-out: one `close` op per target, both in flight at
9439        // once against the one repo's shared admin state. Genuinely concurrent
9440        // even on this single-threaded runtime — each op's prune is a
9441        // `spawn_blocking`, so awaiting its join yields to the other op.
9442        //
9443        // This guards the fan-out end-to-end (both ops complete, neither is
9444        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
9445        // a race detector for the lock, because it is not one — it passes with the
9446        // guard removed, the two prunes being far too quick to collide reliably.
9447        let close = |path: PathBuf| {
9448            let svc = svc.clone();
9449            async move {
9450                svc.handle(
9451                    "close",
9452                    json!({ "path": path, "remove": true, "confirmed": true }),
9453                )
9454                .await
9455            }
9456        };
9457        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
9458
9459        assert_eq!(a.unwrap(), json!({ "removed": true }));
9460        assert_eq!(b.unwrap(), json!({ "removed": true }));
9461        assert!(!first.exists());
9462        assert!(!second.exists());
9463        // Both *admin* entries pruned too, not merely the directories — the half
9464        // the two ops contend on.
9465        let repo = Repository::open(main_dir.path()).unwrap();
9466        assert!(repo.worktrees().unwrap().is_empty());
9467    }
9468
9469    // --- Merge-queue op (#1401) --------------------------------------------
9470
9471    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
9472    /// upstream points at the **same** commit (nothing to push) and a github
9473    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
9474    /// empty (clean) working directory.
9475    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
9476        let repo = init_repo(dir);
9477        let refname = format!("refs/heads/{branch}");
9478        let head = empty_commit(&repo, Some(&refname), &[], "A");
9479        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
9480            .unwrap();
9481        repo.set_head(&refname).unwrap();
9482        let mut cfg = repo.config().unwrap();
9483        cfg.set_str("remote.origin.url", url).unwrap();
9484        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9485            .unwrap();
9486        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
9487            .unwrap();
9488        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
9489            .unwrap();
9490        repo
9491    }
9492
9493    #[test]
9494    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
9495        let dir = tempfile::tempdir().unwrap();
9496        let _repo = pushed_github_repo(
9497            dir.path(),
9498            "https://github.com/rust-works/omni-dev.git",
9499            "feature",
9500        );
9501        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
9502        assert_eq!(
9503            ok.target,
9504            PrTarget {
9505                owner: "rust-works".into(),
9506                name: "omni-dev".into(),
9507                branch: "feature".into(),
9508            }
9509        );
9510        assert!(!ok.head_sha.is_empty());
9511    }
9512
9513    #[test]
9514    fn evaluate_local_skips_an_unborn_head() {
9515        let dir = tempfile::tempdir().unwrap();
9516        let _repo = init_repo(dir.path()); // no commits
9517        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
9518    }
9519
9520    #[test]
9521    fn evaluate_local_skips_a_branch_with_no_upstream() {
9522        let dir = tempfile::tempdir().unwrap();
9523        let repo = init_repo(dir.path());
9524        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9525        repo.set_head("refs/heads/main").unwrap();
9526        // A github URL but no tracking config → nothing was ever pushed.
9527        repo.config()
9528            .unwrap()
9529            .set_str("remote.origin.url", "https://github.com/o/r.git")
9530            .unwrap();
9531        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
9532    }
9533
9534    #[test]
9535    fn evaluate_local_skips_unpushed_local_commits() {
9536        let dir = tempfile::tempdir().unwrap();
9537        let repo = init_repo(dir.path());
9538        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9539        let a_commit = repo.find_commit(a).unwrap();
9540        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
9541        repo.reference("refs/remotes/origin/main", a, true, "o")
9542            .unwrap();
9543        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
9544        drop(a_commit);
9545        repo.set_head("refs/heads/main").unwrap();
9546        let mut cfg = repo.config().unwrap();
9547        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
9548            .unwrap();
9549        // The fetch refspec is what lets git2 map the branch to its tracking ref;
9550        // without it `upstream()` fails and the branch reads as having none.
9551        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
9552            .unwrap();
9553        cfg.set_str("branch.main.remote", "origin").unwrap();
9554        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
9555        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
9556    }
9557
9558    #[test]
9559    fn evaluate_local_skips_a_detached_head() {
9560        let dir = tempfile::tempdir().unwrap();
9561        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
9562        let head = repo.head().unwrap().target().unwrap();
9563        repo.set_head_detached(head).unwrap();
9564        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
9565    }
9566
9567    #[test]
9568    fn evaluate_local_skips_a_non_github_remote() {
9569        let dir = tempfile::tempdir().unwrap();
9570        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
9571        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
9572    }
9573
9574    #[test]
9575    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
9576        // A path git cannot discover a repo from is refused up front (defensive:
9577        // the UI only ever sends real worktrees). A nonexistent path is used so the
9578        // result never depends on whether the temp dir sits inside a checkout.
9579        assert_eq!(
9580            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
9581                .unwrap_err()
9582                .kind,
9583            "not-a-repo"
9584        );
9585    }
9586
9587    #[test]
9588    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
9589        // The audit line's `tracing` field expressions only evaluate when an INFO
9590        // subscriber is active — the sync helper makes that testable.
9591        let req = MergeQueueRequest {
9592            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
9593            requester_key: Some("win-9".into()),
9594            check: true,
9595            confirmed: false,
9596        };
9597        let logs = capture_info(|| log_merge_check(&req, 1, 1));
9598        assert!(logs.contains("merge-queue check"), "{logs}");
9599        assert!(logs.contains("win-9"), "{logs}");
9600        assert!(logs.contains("requested=2"), "{logs}");
9601        assert!(logs.contains("eligible=1"), "{logs}");
9602    }
9603
9604    #[test]
9605    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
9606        // A CLI-style requester (no window key) logs the dash fallback.
9607        let req = MergeQueueRequest {
9608            paths: vec![PathBuf::from("/a")],
9609            requester_key: None,
9610            check: false,
9611            confirmed: true,
9612        };
9613        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
9614        assert!(logs.contains("merge-queue enqueue"), "{logs}");
9615        assert!(logs.contains("queued=2"), "{logs}");
9616        assert!(logs.contains("failed=1"), "{logs}");
9617    }
9618
9619    #[test]
9620    fn evaluate_local_flags_dirty_then_untracked() {
9621        // A linked worktree with a real checked-out file, so status is meaningful.
9622        let main_dir = tempfile::tempdir().unwrap();
9623        let repo = init_repo(main_dir.path());
9624        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
9625        repo.set_head("refs/heads/main").unwrap();
9626        let wt_parent = tempfile::tempdir().unwrap();
9627        let wt_path = wt_parent.path().join("feature-wt");
9628        add_worktree(&repo, a, &wt_path, "feature");
9629
9630        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
9631        let clean = evaluate_local(&wt_path).unwrap_err();
9632        assert_ne!(clean.kind, "dirty");
9633        assert_ne!(clean.kind, "untracked");
9634
9635        // Modify the tracked file → dirty (gate 1, before any network call).
9636        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
9637        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
9638
9639        // Restore it, add a new file → untracked.
9640        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
9641        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
9642        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
9643    }
9644
9645    #[test]
9646    fn is_conflicting_blocks_only_dirty_and_conflicting() {
9647        assert!(is_conflicting(Some("CONFLICTING")));
9648        assert!(is_conflicting(Some("DIRTY")));
9649        assert!(!is_conflicting(Some("CLEAN")));
9650        assert!(!is_conflicting(Some("BLOCKED")));
9651        assert!(!is_conflicting(Some("UNKNOWN")));
9652        assert!(!is_conflicting(None));
9653    }
9654
9655    #[test]
9656    fn merge_queue_request_parses_batch_and_phase_flags() {
9657        let req: MergeQueueRequest = serde_json::from_value(json!({
9658            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
9659        }))
9660        .unwrap();
9661        assert_eq!(req.paths.len(), 2);
9662        assert_eq!(req.requester_key.as_deref(), Some("w1"));
9663        assert!(req.confirmed);
9664        assert!(!req.check);
9665        // Minimal payload: just paths; every other field defaults.
9666        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
9667        assert!(req.paths.is_empty());
9668        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
9669    }
9670
9671    #[test]
9672    fn queued_pr_omits_already_queued_when_false() {
9673        let v = serde_json::to_value(QueuedPr {
9674            path: "/a".into(),
9675            number: 5,
9676            already_queued: false,
9677        })
9678        .unwrap();
9679        assert!(v.get("already_queued").is_none(), "{v}");
9680        let v = serde_json::to_value(QueuedPr {
9681            path: "/a".into(),
9682            number: 5,
9683            already_queued: true,
9684        })
9685        .unwrap();
9686        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
9687    }
9688
9689    #[tokio::test]
9690    async fn merge_queue_check_on_empty_selection_reports_nothing() {
9691        let svc = WorktreesService::new();
9692        let reply = svc
9693            .handle("merge-queue", json!({ "paths": [], "check": true }))
9694            .await
9695            .unwrap();
9696        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
9697    }
9698
9699    #[tokio::test]
9700    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
9701        // An unborn repo is skipped by the *local* gates, so the op never shells
9702        // `gh` — the check completes with no network stub.
9703        let dir = tempfile::tempdir().unwrap();
9704        let _repo = init_repo(dir.path());
9705        let svc = WorktreesService::new();
9706        let reply = svc
9707            .handle(
9708                "merge-queue",
9709                json!({ "paths": [dir.path()], "check": true }),
9710            )
9711            .await
9712            .unwrap();
9713        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
9714        assert_eq!(skipped.len(), 1);
9715        assert_eq!(
9716            skipped[0].get("kind").and_then(Value::as_str),
9717            Some("no-commits")
9718        );
9719        assert!(reply
9720            .get("eligible")
9721            .and_then(Value::as_array)
9722            .unwrap()
9723            .is_empty());
9724    }
9725
9726    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
9727    /// that clears the local gates, so a test can drive the *network* gates by
9728    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
9729    fn ready_worktree() -> (tempfile::TempDir, String) {
9730        let dir = tempfile::tempdir().unwrap();
9731        let repo = pushed_github_repo(
9732            dir.path(),
9733            "https://github.com/rust-works/omni-dev.git",
9734            "feature",
9735        );
9736        let head = repo.head().unwrap().target().unwrap().to_string();
9737        (dir, head)
9738    }
9739
9740    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
9741    /// one check of `conclusion`, and one PR node `pr`.
9742    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
9743        format!(
9744            r#"{{"data":{{"r0":{{"b0":{{
9745                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
9746                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
9747                ]}}}}}},
9748                "associatedPullRequests":{{"nodes":[{pr}]}}
9749            }}}}}}}}"#
9750        )
9751    }
9752
9753    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
9754    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
9755    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
9756    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
9757        let ghdir = tempfile::tempdir().unwrap();
9758        let (bin, _shim) = fake_gh(ghdir.path(), reply);
9759        let paths = vec![head_dir.to_path_buf()];
9760        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
9761        if let Some(e) = eligible.first() {
9762            return Ok(e.number);
9763        }
9764        Err(skipped.remove(0).kind)
9765    }
9766
9767    #[test]
9768    fn evaluate_batch_marks_a_ready_pr_eligible() {
9769        let (dir, head) = ready_worktree();
9770        let pr = format!(
9771            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9772        );
9773        assert_eq!(
9774            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9775            Ok(9)
9776        );
9777    }
9778
9779    #[test]
9780    fn evaluate_batch_skips_a_draft_pr() {
9781        let (dir, head) = ready_worktree();
9782        let pr = format!(
9783            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9784        );
9785        assert_eq!(
9786            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9787            Err("draft".to_string())
9788        );
9789    }
9790
9791    #[test]
9792    fn evaluate_batch_skips_a_conflicting_pr() {
9793        let (dir, head) = ready_worktree();
9794        let pr = format!(
9795            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
9796        );
9797        assert_eq!(
9798            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
9799            Err("conflicting".to_string())
9800        );
9801    }
9802
9803    #[test]
9804    fn evaluate_batch_skips_a_pr_with_failing_checks() {
9805        let (dir, head) = ready_worktree();
9806        let pr = format!(
9807            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9808        );
9809        assert_eq!(
9810            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
9811            Err("checks-failing".to_string())
9812        );
9813    }
9814
9815    #[test]
9816    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
9817        let (dir, head) = ready_worktree();
9818        // The remote PR head is a different commit than the local head.
9819        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
9820        assert_eq!(
9821            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
9822            Err("stale".to_string())
9823        );
9824    }
9825
9826    #[test]
9827    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
9828        let (dir, head) = ready_worktree();
9829        // The ref resolves but no open PR heads it.
9830        let reply = format!(
9831            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
9832        );
9833        assert_eq!(
9834            network_gate_outcome(dir.path(), &reply),
9835            Err("no-pr".to_string())
9836        );
9837    }
9838
9839    #[test]
9840    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
9841        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
9842        // already-queued PR needs no mutation and is reported queued.
9843        let eligible = vec![
9844            Eligible {
9845                path: PathBuf::from("/wt/a"),
9846                number: 1,
9847                url: "u".into(),
9848                branch: "a".into(),
9849                pr_id: "PR_A".into(),
9850                already_queued: true,
9851            },
9852            Eligible {
9853                path: PathBuf::from("/wt/b"),
9854                number: 2,
9855                url: "u".into(),
9856                branch: "b".into(),
9857                pr_id: "PR_B".into(),
9858                already_queued: false,
9859            },
9860        ];
9861        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
9862        assert_eq!(queued.len(), 1);
9863        assert_eq!(queued[0].number, 1);
9864        assert!(queued[0].already_queued);
9865        assert_eq!(failed.len(), 1);
9866        assert_eq!(failed[0].number, 2);
9867    }
9868
9869    #[test]
9870    fn enqueue_eligible_records_a_github_rejection_as_failed() {
9871        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
9872        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
9873        // rather than sinking the batch.
9874        let ghdir = tempfile::tempdir().unwrap();
9875        let (bin, _shim) = fake_gh(
9876            ghdir.path(),
9877            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
9878        );
9879        let eligible = vec![Eligible {
9880            path: PathBuf::from("/wt/a"),
9881            number: 7,
9882            url: "u".into(),
9883            branch: "a".into(),
9884            pr_id: "PR_A".into(),
9885            already_queued: false,
9886        }];
9887        let (queued, failed) = enqueue_eligible(&bin, eligible);
9888        assert!(queued.is_empty(), "{queued:?}");
9889        assert_eq!(failed.len(), 1);
9890        assert_eq!(failed[0].number, 7);
9891        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
9892        // instead surface as an `Err`, still landing in `failed[]`).
9893        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
9894    }
9895
9896    #[tokio::test]
9897    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9898    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
9899        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
9900        // exercising the `PrRef` mapping the empty-selection tests cannot.
9901        let (dir, head) = ready_worktree();
9902        let pr = format!(
9903            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9904        );
9905        let ghdir = tempfile::tempdir().unwrap();
9906        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
9907        let svc = WorktreesService::new();
9908        let reply = svc
9909            .merge_queue_with(
9910                MergeQueueRequest {
9911                    paths: vec![dir.path().to_path_buf()],
9912                    requester_key: None,
9913                    check: true,
9914                    confirmed: false,
9915                },
9916                bin,
9917            )
9918            .await
9919            .unwrap();
9920        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
9921        assert_eq!(eligible.len(), 1);
9922        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
9923        assert_eq!(
9924            eligible[0].get("branch").and_then(Value::as_str),
9925            Some("feature")
9926        );
9927    }
9928
9929    #[tokio::test]
9930    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
9931    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
9932        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
9933        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
9934        let (dir, head) = ready_worktree();
9935        let pr = format!(
9936            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
9937        );
9938        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
9939        let ghdir = tempfile::tempdir().unwrap();
9940        let guard = shim_lock();
9941        let bin = ghdir.path().join("fake-gh");
9942        write_exec_script(
9943            &bin,
9944            &format!(
9945                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
9946                enqueue =
9947                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
9948            ),
9949        );
9950        let svc = WorktreesService::new();
9951        let reply = svc
9952            .merge_queue_with(
9953                MergeQueueRequest {
9954                    paths: vec![dir.path().to_path_buf()],
9955                    requester_key: Some("w1".into()),
9956                    check: false,
9957                    confirmed: true,
9958                },
9959                bin,
9960            )
9961            .await
9962            .unwrap();
9963        drop(guard);
9964        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
9965        assert_eq!(queued.len(), 1, "{reply}");
9966        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
9967        assert!(reply
9968            .get("failed")
9969            .and_then(Value::as_array)
9970            .unwrap()
9971            .is_empty());
9972    }
9973
9974    #[tokio::test]
9975    async fn concurrent_closes_overlap_their_heartbeat_waits() {
9976        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
9977        let svc = Arc::new(WorktreesService::new());
9978        // Two *different* windows own the two targets — the multi-select shape.
9979        for (key, path) in [("w2", &first), ("w3", &second)] {
9980            svc.handle("register", json!({ "key": key, "folders": [path] }))
9981                .await
9982                .unwrap();
9983        }
9984
9985        let spawn_close = |path: PathBuf| {
9986            let svc = svc.clone();
9987            tokio::spawn(async move {
9988                svc.handle(
9989                    "close",
9990                    json!({
9991                        "path": path,
9992                        "remove": true,
9993                        "confirmed": true,
9994                        "requester_key": "w1",
9995                    }),
9996                )
9997                .await
9998            })
9999        };
10000        let a = spawn_close(first.clone());
10001        let b = spawn_close(second.clone());
10002
10003        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
10004        // *both* windows are told to close while *neither* op has finished, so the
10005        // two multi-second heartbeat waits are in flight at once. Take the guard
10006        // before `await_windows_closed` instead of after and this fails — op B
10007        // would sit on the lock without ever marking w3, restoring exactly the
10008        // N-stacked-waits latency the fan-out exists to remove.
10009        for key in ["w2", "w3"] {
10010            let mut saw_close = false;
10011            for _ in 0..400 {
10012                let hb = svc
10013                    .handle("heartbeat", json!({ "key": key }))
10014                    .await
10015                    .unwrap();
10016                if hb.get("close").and_then(Value::as_bool) == Some(true) {
10017                    saw_close = true;
10018                    break;
10019                }
10020                tokio::time::sleep(Duration::from_millis(5)).await;
10021            }
10022            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10023        }
10024        assert!(
10025            !a.is_finished() && !b.is_finished(),
10026            "neither close can have finished: both windows are still registered"
10027        );
10028
10029        // Both windows close; both ops then remove.
10030        for key in ["w2", "w3"] {
10031            svc.handle("unregister", json!({ "key": key }))
10032                .await
10033                .unwrap();
10034        }
10035        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10036        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10037        assert!(!first.exists());
10038        assert!(!second.exists());
10039    }
10040
10041    #[tokio::test]
10042    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10043        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10044        let svc = WorktreesService::new();
10045        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
10046        // main, no risks → the extension proceeds with no dialog.
10047        let report = svc
10048            .handle("close", json!({ "path": wt_path, "remove": true }))
10049            .await
10050            .unwrap();
10051        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10052        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10053        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10054        assert!(report
10055            .get("risks")
10056            .and_then(Value::as_array)
10057            .unwrap()
10058            .is_empty());
10059        // No side effects: the worktree still exists.
10060        assert!(wt_path.exists());
10061    }
10062
10063    #[tokio::test]
10064    async fn close_removes_a_clean_linked_worktree() {
10065        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10066        let svc = WorktreesService::new();
10067        let reply = svc
10068            .handle(
10069                "close",
10070                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10071            )
10072            .await
10073            .unwrap();
10074        assert_eq!(reply, json!({ "removed": true }));
10075        assert!(
10076            !wt_path.exists(),
10077            "the worktree directory should be deleted"
10078        );
10079    }
10080
10081    // --- Close-op audit logging (#1364) ------------------------------------
10082
10083    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
10084    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
10085    /// `with_default`, so it never disturbs a global subscriber other tests set.
10086    /// The audit-line tests drive the sync helpers directly (no runtime, no
10087    /// `spawn_blocking`), so the captured events fire on this thread where the
10088    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
10089    /// under the parallel suite is *not* reliably captured this way.
10090    #[derive(Clone, Default)]
10091    struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
10092
10093    impl std::io::Write for CaptureWriter {
10094        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
10095            self.0.lock().unwrap().extend_from_slice(buf);
10096            Ok(buf.len())
10097        }
10098        fn flush(&mut self) -> std::io::Result<()> {
10099            Ok(())
10100        }
10101    }
10102
10103    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
10104        type Writer = Self;
10105        fn make_writer(&'a self) -> Self::Writer {
10106            self.clone()
10107        }
10108    }
10109
10110    /// Runs `f` under a thread-local INFO-level subscriber and returns everything
10111    /// it logged. `f` must be fully synchronous on this thread.
10112    fn capture_info(f: impl FnOnce()) -> String {
10113        let writer = CaptureWriter::default();
10114        let subscriber = tracing_subscriber::fmt()
10115            .with_max_level(tracing::Level::INFO)
10116            .with_ansi(false)
10117            .with_writer(writer.clone())
10118            .finish();
10119        tracing::subscriber::with_default(subscriber, f);
10120        let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
10121        logs
10122    }
10123
10124    // ── rebase op (#1415) ──────────────────────────────────────────────────
10125
10126    /// A `RebaseRequest` over `paths` with everything else defaulted.
10127    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10128        RebaseRequest {
10129            paths,
10130            requester_key: None,
10131            check: false,
10132            confirmed: false,
10133            keep_conflicts: false,
10134            autostash: false,
10135            onto: None,
10136        }
10137    }
10138
10139    /// A repo whose `main` has advanced one commit past a linked `feature`
10140    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
10141    ///
10142    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
10143    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
10144    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
10145    /// own concern and is covered in `worktree_rebase.rs`.
10146    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10147        let main_dir = tempfile::tempdir().unwrap();
10148        let repo = init_repo(main_dir.path());
10149        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10150        repo.set_head("refs/heads/main").unwrap();
10151        let wt_parent = tempfile::tempdir().unwrap();
10152        let wt_path = wt_parent.path().join("feature-wt");
10153        add_worktree(&repo, base, &wt_path, "feature");
10154        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
10155        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10156        (main_dir, wt_parent, wt_path)
10157    }
10158
10159    #[tokio::test]
10160    async fn rebase_with_refuses_an_empty_selection() {
10161        // A bare `rebase` must be a usage error, never a silent mass-rebase.
10162        let svc = WorktreesService::new();
10163        let err = svc
10164            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10165            .await
10166            .unwrap_err()
10167            .to_string();
10168        assert!(err.contains("at least one path"), "{err}");
10169    }
10170
10171    #[tokio::test]
10172    async fn rebase_with_phase_one_reports_without_rebasing() {
10173        let (_main, _parent, wt) = behind_worktree();
10174        let before = Repository::open(&wt).unwrap().head().unwrap().target();
10175
10176        let svc = WorktreesService::new();
10177        let reply = svc
10178            .rebase_with(
10179                RebaseRequest {
10180                    check: true,
10181                    onto: Some("main".into()),
10182                    ..rebase_req(vec![wt.clone()])
10183                },
10184                crate::git::resolve_git_binary(),
10185            )
10186            .await
10187            .unwrap();
10188
10189        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10190        assert_eq!(worktrees.len(), 1, "{reply}");
10191        assert_eq!(
10192            worktrees[0].get("status").and_then(Value::as_str),
10193            Some("would-rebase"),
10194            "{reply}"
10195        );
10196        // A local onto ref means no fetch was attempted at all.
10197        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
10198        assert_eq!(fetches.len(), 1);
10199        assert_eq!(
10200            fetches[0].get("fetched").and_then(Value::as_bool),
10201            Some(false)
10202        );
10203        assert_eq!(
10204            Repository::open(&wt).unwrap().head().unwrap().target(),
10205            before,
10206            "phase 1 must not move the branch"
10207        );
10208    }
10209
10210    #[tokio::test]
10211    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
10212        let (_main, _parent, wt) = behind_worktree();
10213        let svc = WorktreesService::new();
10214        let reply = svc
10215            .rebase_with(
10216                RebaseRequest {
10217                    confirmed: true,
10218                    onto: Some("main".into()),
10219                    ..rebase_req(vec![wt.clone()])
10220                },
10221                crate::git::resolve_git_binary(),
10222            )
10223            .await
10224            .unwrap();
10225
10226        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10227        assert_eq!(
10228            worktrees[0].get("status").and_then(Value::as_str),
10229            Some("rebased"),
10230            "{reply}"
10231        );
10232        // The transient cue is cleared on the way out, so no row keeps spinning.
10233        assert!(
10234            svc.registry.rebasing_paths().is_empty(),
10235            "the rebasing mark must be cleared after the execute"
10236        );
10237    }
10238
10239    #[tokio::test]
10240    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
10241        // The re-validation that makes two-phase meaningful: a `confirmed` request
10242        // still runs the classifier, so a worktree that is dirty *now* is skipped
10243        // rather than rebased on the strength of an earlier phase-1 verdict.
10244        let (_main, _parent, wt) = behind_worktree();
10245        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
10246
10247        let svc = WorktreesService::new();
10248        let reply = svc
10249            .rebase_with(
10250                RebaseRequest {
10251                    confirmed: true,
10252                    onto: Some("main".into()),
10253                    ..rebase_req(vec![wt.clone()])
10254                },
10255                crate::git::resolve_git_binary(),
10256            )
10257            .await
10258            .unwrap();
10259
10260        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10261        assert_eq!(
10262            worktrees[0].get("status").and_then(Value::as_str),
10263            Some("skipped"),
10264            "{reply}"
10265        );
10266        assert_eq!(
10267            worktrees[0].get("reason").and_then(Value::as_str),
10268            Some("dirty"),
10269            "{reply}"
10270        );
10271    }
10272
10273    #[tokio::test]
10274    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
10275        // The hazard the plan-under-the-lock ordering exists to prevent: if another
10276        // run has left a worktree mid-rebase, this one must classify it as
10277        // `operation-in-progress` and leave it alone — never run `git rebase`
10278        // against it, which (without `keep_conflicts`) would `--abort` and destroy
10279        // the conflict resolution in progress.
10280        let (_main, _parent, wt) = behind_worktree();
10281        // Fake a rebase in progress the way git does: the state directory's
10282        // presence is what `Repository::state()` keys on.
10283        std::fs::create_dir_all(wt.join(".git")).ok();
10284        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
10285        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
10286        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
10287        assert_ne!(
10288            Repository::open(&wt).unwrap().state(),
10289            RepositoryState::Clean,
10290            "precondition: the worktree looks mid-rebase to git2"
10291        );
10292
10293        let svc = WorktreesService::new();
10294        let reply = svc
10295            .rebase_with(
10296                RebaseRequest {
10297                    confirmed: true,
10298                    onto: Some("main".into()),
10299                    ..rebase_req(vec![wt.clone()])
10300                },
10301                crate::git::resolve_git_binary(),
10302            )
10303            .await
10304            .unwrap();
10305
10306        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10307        assert_eq!(
10308            worktrees[0].get("reason").and_then(Value::as_str),
10309            Some("operation-in-progress"),
10310            "{reply}"
10311        );
10312        // Still mid-rebase: nothing aborted it out from under whoever owns it.
10313        assert_ne!(
10314            Repository::open(&wt).unwrap().state(),
10315            RepositoryState::Clean
10316        );
10317    }
10318
10319    #[test]
10320    fn rebase_request_maps_onto_engine_options() {
10321        let req = RebaseRequest {
10322            keep_conflicts: true,
10323            autostash: true,
10324            onto: Some("origin/release".into()),
10325            ..rebase_req(vec![PathBuf::from("/wt")])
10326        };
10327        let opts = req.options(PathBuf::from("/custom/git"));
10328        assert!(opts.keep_conflicts && opts.autostash);
10329        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
10330        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
10331        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
10332        // engine's own flag stays off — setting it would be a second, redundant
10333        // gate that could silently no-op a confirmed execute.
10334        assert!(!opts.dry_run);
10335    }
10336
10337    #[test]
10338    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
10339        let req = RebaseRequest {
10340            requester_key: Some("win-3".into()),
10341            check: true,
10342            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10343        };
10344        let plan = worktree_rebase::Plan {
10345            fetches: vec![worktree_rebase::FetchOutcome {
10346                repo_root: PathBuf::from("/repo"),
10347                onto: "origin/main".into(),
10348                fetched: true,
10349                ok: false,
10350                detail: Some("host unreachable".into()),
10351            }],
10352            worktrees: vec![
10353                worktree_rebase::WorktreeOutcome {
10354                    path: PathBuf::from("/a"),
10355                    branch: Some("a".into()),
10356                    onto: "origin/main".into(),
10357                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
10358                },
10359                worktree_rebase::WorktreeOutcome {
10360                    path: PathBuf::from("/b"),
10361                    branch: Some("b".into()),
10362                    onto: "origin/main".into(),
10363                    result: worktree_rebase::RebaseResult::UpToDate,
10364                },
10365            ],
10366        };
10367        let logs = capture_info(|| log_rebase_check(&req, &plan));
10368        assert!(logs.contains("rebase check"), "{logs}");
10369        assert!(logs.contains("win-3"), "{logs}");
10370        assert!(logs.contains("requested=2"), "{logs}");
10371        assert!(logs.contains("pending=1"), "{logs}");
10372        assert!(logs.contains("failed_fetches=1"), "{logs}");
10373    }
10374
10375    #[test]
10376    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
10377        // A CLI-style requester (no window key) logs the dash fallback.
10378        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
10379        let outcome = |result| worktree_rebase::WorktreeOutcome {
10380            path: PathBuf::from("/x"),
10381            branch: Some("x".into()),
10382            onto: "origin/main".into(),
10383            result,
10384        };
10385        let outcomes = vec![
10386            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
10387            outcome(worktree_rebase::RebaseResult::Conflict {
10388                detail: "CONFLICT".into(),
10389                left_in_place: true,
10390            }),
10391            outcome(worktree_rebase::RebaseResult::Skipped {
10392                reason: worktree_rebase::SkipReason::Dirty,
10393            }),
10394        ];
10395        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
10396        assert!(logs.contains("rebase execute"), "{logs}");
10397        assert!(logs.contains("rebased=1"), "{logs}");
10398        assert!(logs.contains("conflicts=1"), "{logs}");
10399        assert!(logs.contains("left_in_place=1"), "{logs}");
10400        assert!(logs.contains("skipped=1"), "{logs}");
10401        assert!(logs.contains(r#"requester="-""#), "{logs}");
10402    }
10403
10404    // --- Push op (#1443) ---------------------------------------------------
10405
10406    /// A `PushRequest` with every field defaulted, for terse test construction.
10407    fn push_req(paths: Vec<PathBuf>) -> PushRequest {
10408        PushRequest {
10409            paths,
10410            requester_key: None,
10411            check: false,
10412            confirmed: false,
10413        }
10414    }
10415
10416    /// A bare `origin`, a local clone of `main`, and a linked `feature` worktree
10417    /// whose branch is published and then **rewritten** — i.e. exactly what a
10418    /// rebase leaves behind, the state `push` exists for.
10419    ///
10420    /// Shells out to `git` (so the push has a real remote to talk to) under the
10421    /// shared serialization guard the other git-heavy tests use.
10422    fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
10423        // Held for the fixture only — that dozen-subprocess burst is what the lock
10424        // exists to cap — and released on return, before any `.await` in the test.
10425        let _guard = crate::git::worktree_batch::test_serial_lock();
10426        let root = tempfile::tempdir().unwrap();
10427        let origin = root.path().join("origin.git");
10428        let local = root.path().join("local");
10429        let wt = root.path().join("feature-wt");
10430        std::fs::create_dir_all(&origin).unwrap();
10431        std::fs::create_dir_all(&local).unwrap();
10432
10433        let git = |dir: &Path, args: &[&str]| {
10434            let out = crate::git::worktree_batch::run_git_in(
10435                &crate::git::resolve_git_binary(),
10436                dir,
10437                args,
10438            )
10439            .unwrap();
10440            assert!(
10441                out.status.success(),
10442                "git {args:?} failed: {}",
10443                String::from_utf8_lossy(&out.stderr)
10444            );
10445        };
10446
10447        git(&origin, &["init", "--bare", "-b", "main"]);
10448        git(&local, &["init", "-b", "main"]);
10449        git(&local, &["config", "user.name", "Test"]);
10450        git(&local, &["config", "user.email", "test@example.com"]);
10451        git(&local, &["config", "commit.gpgsign", "false"]);
10452        std::fs::write(local.join("f.txt"), "base\n").unwrap();
10453        git(&local, &["add", "f.txt"]);
10454        git(&local, &["commit", "-m", "base"]);
10455        git(
10456            &local,
10457            &["remote", "add", "origin", origin.to_str().unwrap()],
10458        );
10459        git(&local, &["push", "-u", "origin", "main"]);
10460        git(
10461            &local,
10462            &[
10463                "worktree",
10464                "add",
10465                "-b",
10466                "feature",
10467                wt.to_str().unwrap(),
10468                "main",
10469            ],
10470        );
10471        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
10472        git(&wt, &["add", "g.txt"]);
10473        git(&wt, &["commit", "-m", "work"]);
10474        git(&wt, &["push", "-u", "origin", "feature"]);
10475        // The rewrite: `feature` now diverges from `origin/feature`.
10476        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
10477
10478        (root, origin, std::fs::canonicalize(&wt).unwrap())
10479    }
10480
10481    /// The tip of `refname` in the bare origin, when it exists.
10482    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
10483        Repository::open_bare(origin)
10484            .unwrap()
10485            .refname_to_id(refname)
10486            .ok()
10487    }
10488
10489    #[tokio::test]
10490    async fn push_with_refuses_an_empty_selection() {
10491        // A bare `push` must be a usage error, never a silent mass-push.
10492        let svc = WorktreesService::new();
10493        let err = svc
10494            .push_with(push_req(Vec::new()), PathBuf::from("git"))
10495            .await
10496            .unwrap_err()
10497            .to_string();
10498        assert!(err.contains("at least one path"), "{err}");
10499    }
10500
10501    #[tokio::test]
10502    async fn push_with_phase_one_reports_without_publishing() {
10503        let (_root, origin, wt) = rewritten_worktree();
10504        let before = origin_tip(&origin, "refs/heads/feature");
10505
10506        let svc = WorktreesService::new();
10507        let reply = svc
10508            .push_with(
10509                PushRequest {
10510                    check: true,
10511                    ..push_req(vec![wt.clone()])
10512                },
10513                crate::git::resolve_git_binary(),
10514            )
10515            .await
10516            .unwrap();
10517
10518        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10519        assert_eq!(worktrees.len(), 1, "{reply}");
10520        assert_eq!(
10521            worktrees[0].get("status").and_then(Value::as_str),
10522            Some("would-force"),
10523            "{reply}"
10524        );
10525        assert!(
10526            reply.get("fetches").is_none(),
10527            "a push plan contacts no remote, so it reports no fetches: {reply}"
10528        );
10529        assert_eq!(
10530            origin_tip(&origin, "refs/heads/feature"),
10531            before,
10532            "phase 1 must publish nothing"
10533        );
10534        assert!(
10535            svc.registry.pushing_paths().is_empty(),
10536            "phase 1 must not mark a row as in flight"
10537        );
10538    }
10539
10540    #[tokio::test]
10541    async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
10542        let (_root, origin, wt) = rewritten_worktree();
10543        let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
10544
10545        let svc = WorktreesService::new();
10546        let reply = svc
10547            .push_with(
10548                PushRequest {
10549                    confirmed: true,
10550                    ..push_req(vec![wt.clone()])
10551                },
10552                crate::git::resolve_git_binary(),
10553            )
10554            .await
10555            .unwrap();
10556
10557        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10558        assert_eq!(
10559            worktrees[0].get("status").and_then(Value::as_str),
10560            Some("pushed"),
10561            "{reply}"
10562        );
10563        assert_eq!(
10564            worktrees[0].get("forced").and_then(Value::as_bool),
10565            Some(true),
10566            "a rewritten branch is published under the lease: {reply}"
10567        );
10568        assert_eq!(
10569            origin_tip(&origin, "refs/heads/feature"),
10570            rewritten,
10571            "the remote must carry the rewritten tip"
10572        );
10573        assert!(
10574            svc.registry.pushing_paths().is_empty(),
10575            "the pushing mark must be cleared after the execute — a push writes no \
10576             on-disk state, so nothing else could ever correct a leftover"
10577        );
10578    }
10579
10580    #[tokio::test]
10581    async fn push_resolves_the_git_binary_for_itself() {
10582        // The public entry point, which `push_with` exists to let the other tests
10583        // bypass. Safe to call for real: a `check` never reaches a subprocess at
10584        // all, because planning a push contacts no remote.
10585        let (_root, origin, wt) = rewritten_worktree();
10586        let before = origin_tip(&origin, "refs/heads/feature");
10587
10588        let svc = WorktreesService::new();
10589        let reply = svc
10590            .push(PushRequest {
10591                check: true,
10592                ..push_req(vec![wt])
10593            })
10594            .await
10595            .unwrap();
10596
10597        assert_eq!(
10598            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10599                .get("status")
10600                .and_then(Value::as_str),
10601            Some("would-force"),
10602            "{reply}"
10603        );
10604        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10605    }
10606
10607    #[tokio::test]
10608    async fn push_with_defaults_to_report_only_without_confirmation() {
10609        // Neither `check` nor `confirmed`: the safe reading is "report", matching
10610        // `rebase`. A client that forgets the flag must never publish.
10611        let (_root, origin, wt) = rewritten_worktree();
10612        let before = origin_tip(&origin, "refs/heads/feature");
10613
10614        let svc = WorktreesService::new();
10615        let reply = svc
10616            .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
10617            .await
10618            .unwrap();
10619
10620        assert_eq!(
10621            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
10622                .get("status")
10623                .and_then(Value::as_str),
10624            Some("would-force"),
10625            "{reply}"
10626        );
10627        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
10628    }
10629
10630    #[tokio::test]
10631    async fn push_with_refuses_to_force_the_remote_default_branch() {
10632        // The gate that inverts ADR-0060, enforced in the daemon rather than only
10633        // in the UI: a rewritten `main` is reported, never published.
10634        let (root, origin, _wt) = rewritten_worktree();
10635        let local = root.path().join("local");
10636        let before = origin_tip(&origin, "refs/heads/main");
10637        crate::git::worktree_batch::run_git_in(
10638            &crate::git::resolve_git_binary(),
10639            &local,
10640            &["commit", "--amend", "-m", "rewritten main"],
10641        )
10642        .unwrap();
10643
10644        let svc = WorktreesService::new();
10645        let reply = svc
10646            .push_with(
10647                PushRequest {
10648                    confirmed: true,
10649                    ..push_req(vec![local.clone()])
10650                },
10651                crate::git::resolve_git_binary(),
10652            )
10653            .await
10654            .unwrap();
10655
10656        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
10657        assert_eq!(
10658            worktrees[0].get("reason").and_then(Value::as_str),
10659            Some("default-branch-force-push"),
10660            "{reply}"
10661        );
10662        assert_eq!(
10663            origin_tip(&origin, "refs/heads/main"),
10664            before,
10665            "the default branch's published history must be untouched"
10666        );
10667    }
10668
10669    #[test]
10670    fn log_push_check_separates_the_force_count_from_the_pending_count() {
10671        let req = PushRequest {
10672            requester_key: Some("win-7".into()),
10673            check: true,
10674            ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
10675        };
10676        let outcome = |result| worktree_push::WorktreeOutcome {
10677            path: PathBuf::from("/x"),
10678            branch: Some("x".into()),
10679            remote: "origin".into(),
10680            remote_branch: "x".into(),
10681            result,
10682        };
10683        let plan = worktree_push::Plan {
10684            worktrees: vec![
10685                outcome(worktree_push::PushResult::WouldForce {
10686                    ahead: 1,
10687                    behind: 1,
10688                }),
10689                outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
10690                outcome(worktree_push::PushResult::Skipped {
10691                    reason: worktree_push::SkipReason::DefaultBranchForcePush,
10692                }),
10693            ],
10694        };
10695        let logs = capture_info(|| log_push_check(&req, &plan));
10696        assert!(logs.contains("push check"), "{logs}");
10697        assert!(logs.contains("pending=2"), "{logs}");
10698        assert!(logs.contains("forced=1"), "{logs}");
10699        assert!(logs.contains("skipped=1"), "{logs}");
10700        assert!(logs.contains(r#"requester="win-7""#), "{logs}");
10701    }
10702
10703    #[test]
10704    fn log_push_execute_counts_lease_refusals_separately() {
10705        let req = push_req(vec![PathBuf::from("/a")]);
10706        let outcome = |result| worktree_push::WorktreeOutcome {
10707            path: PathBuf::from("/x"),
10708            branch: Some("x".into()),
10709            remote: "origin".into(),
10710            remote_branch: "x".into(),
10711            result,
10712        };
10713        let outcomes = vec![
10714            outcome(worktree_push::PushResult::Pushed { forced: true }),
10715            outcome(worktree_push::PushResult::Pushed { forced: false }),
10716            outcome(worktree_push::PushResult::Created),
10717            outcome(worktree_push::PushResult::Rejected {
10718                detail: "stale info".into(),
10719                stale: true,
10720            }),
10721            outcome(worktree_push::PushResult::Rejected {
10722                detail: "pre-receive hook declined".into(),
10723                stale: false,
10724            }),
10725        ];
10726        let logs = capture_info(|| log_push_execute(&req, &outcomes));
10727        assert!(logs.contains("push execute"), "{logs}");
10728        assert!(logs.contains("pushed=2"), "{logs}");
10729        assert!(logs.contains("forced=1"), "{logs}");
10730        assert!(logs.contains("created=1"), "{logs}");
10731        assert!(logs.contains("rejected=2"), "{logs}");
10732        assert!(
10733            logs.contains("stale_rejected=1"),
10734            "a lease refusal is the interesting half of a rejection: {logs}"
10735        );
10736    }
10737
10738    #[test]
10739    fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
10740        let dir = tempfile::tempdir().unwrap();
10741        let path = canonical(dir.path());
10742
10743        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10744        assert!(!quiet.pushing);
10745        let json = serde_json::to_value(&quiet).unwrap();
10746        assert!(
10747            json.get("pushing").is_none(),
10748            "an idle row stays byte-identical for an older client: {json}"
10749        );
10750
10751        let busy = worktree_entry(
10752            &path,
10753            true,
10754            &HashMap::new(),
10755            &InFlight {
10756                pushing: [path.clone()].into(),
10757                rebasing: HashSet::new(),
10758            },
10759        );
10760        assert!(busy.pushing, "the registry's transient mark rides through");
10761        assert!(
10762            !busy.rebasing,
10763            "the two cues are independent — a push must not read as a rebase"
10764        );
10765        assert_eq!(
10766            serde_json::to_value(&busy).unwrap()["pushing"],
10767            serde_json::json!(true)
10768        );
10769    }
10770
10771    #[test]
10772    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
10773        assert_eq!(operation_slug(RepositoryState::Clean), None);
10774        assert_eq!(
10775            operation_slug(RepositoryState::Rebase).as_deref(),
10776            Some("rebase")
10777        );
10778        assert_eq!(
10779            operation_slug(RepositoryState::RebaseMerge).as_deref(),
10780            Some("rebase"),
10781            "the merge-backend rebase is still just a rebase to the user"
10782        );
10783        assert_eq!(
10784            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
10785            Some("rebase-interactive")
10786        );
10787        assert_eq!(
10788            operation_slug(RepositoryState::Merge).as_deref(),
10789            Some("merge")
10790        );
10791        assert_eq!(
10792            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
10793            Some("cherry-pick")
10794        );
10795        assert_eq!(
10796            operation_slug(RepositoryState::RevertSequence).as_deref(),
10797            Some("revert")
10798        );
10799        assert_eq!(
10800            operation_slug(RepositoryState::Bisect).as_deref(),
10801            Some("bisect")
10802        );
10803        assert_eq!(
10804            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
10805            Some("apply-mailbox")
10806        );
10807    }
10808
10809    #[test]
10810    fn git_status_omits_operation_for_a_clean_worktree() {
10811        let dir = tempfile::tempdir().unwrap();
10812        let _repo = diverging_repo(dir.path());
10813        assert_eq!(
10814            git_status(dir.path()).operation,
10815            None,
10816            "a clean worktree carries no operation, so the field stays off the wire"
10817        );
10818    }
10819
10820    #[test]
10821    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
10822        let main_dir = tempfile::tempdir().unwrap();
10823        let repo = init_repo(main_dir.path());
10824        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10825        repo.set_head("refs/heads/main").unwrap();
10826        let path = canonical(main_dir.path());
10827
10828        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
10829        assert!(!quiet.rebasing);
10830        // Byte-identical for a pre-#1415 client: neither new field is serialized.
10831        let json = serde_json::to_value(&quiet).unwrap();
10832        assert!(json.get("rebasing").is_none(), "{json}");
10833        assert!(json.get("operation").is_none(), "{json}");
10834
10835        let busy = worktree_entry(
10836            &path,
10837            true,
10838            &HashMap::new(),
10839            &InFlight {
10840                rebasing: std::iter::once(path.clone()).collect(),
10841                pushing: HashSet::new(),
10842            },
10843        );
10844        assert!(busy.rebasing, "the registry's transient mark rides through");
10845        assert_eq!(
10846            serde_json::to_value(&busy).unwrap()["rebasing"],
10847            serde_json::Value::Bool(true)
10848        );
10849    }
10850
10851    #[test]
10852    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
10853        assert_eq!(note_kinds(&[]), "-");
10854        assert_eq!(
10855            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
10856            "dirty,untracked"
10857        );
10858    }
10859
10860    #[test]
10861    fn is_self_close_true_only_when_requester_owns_an_open_window() {
10862        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
10863        assert!(is_self_close(Some("w1"), &windows));
10864        assert!(
10865            !is_self_close(Some("w3"), &windows),
10866            "requester owns no window"
10867        );
10868        assert!(!is_self_close(None, &windows), "no requester");
10869        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
10870    }
10871
10872    #[test]
10873    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
10874        let logs = capture_info(|| {
10875            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
10876            assert_eq!(reply, json!({ "removed": true }));
10877        });
10878        assert!(
10879            logs.contains("worktrees close: linked worktree pruned"),
10880            "a successful prune must log an INFO audit line, got: {logs}"
10881        );
10882        assert!(
10883            logs.contains("/wt/feature"),
10884            "the target path must ride the line, got: {logs}"
10885        );
10886    }
10887
10888    #[test]
10889    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
10890        // The #1403 fix: an already-removed worktree still replies `removed: true`
10891        // (the row should go) but must NOT log the `pruned` line — it logs the
10892        // distinct `already-gone` outcome so the audit trail stops conflating the
10893        // two.
10894        let logs = capture_info(|| {
10895            let reply =
10896                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
10897            assert_eq!(reply, json!({ "removed": true }));
10898        });
10899        assert!(
10900            logs.contains("worktrees close: nothing to prune, worktree already removed"),
10901            "an already-gone close must log its own outcome, got: {logs}"
10902        );
10903        assert!(
10904            !logs.contains("linked worktree pruned"),
10905            "an already-gone close must not claim it pruned, got: {logs}"
10906        );
10907    }
10908
10909    #[test]
10910    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
10911        // ERROR is more severe than the INFO cap, so `capture_info` records it.
10912        let logs = capture_info(|| {
10913            let err = log_close_error(
10914                Path::new("/wt/feature"),
10915                "safety check",
10916                anyhow!("not a git worktree"),
10917            );
10918            assert_eq!(
10919                err.to_string(),
10920                "not a git worktree",
10921                "err propagates unchanged"
10922            );
10923        });
10924        assert!(
10925            logs.contains("worktrees close: safety check failed"),
10926            "a failed phase must log an ERROR audit line, got: {logs}"
10927        );
10928        assert!(
10929            logs.contains("not a git worktree"),
10930            "the cause must ride the line, got: {logs}"
10931        );
10932        assert!(
10933            logs.contains("/wt/feature"),
10934            "the target path must ride the line, got: {logs}"
10935        );
10936    }
10937
10938    #[test]
10939    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
10940        let logs = capture_info(|| {
10941            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
10942            assert!(err.is_err(), "a prune failure must propagate");
10943        });
10944        assert!(
10945            logs.contains("worktrees close: worktree prune failed"),
10946            "a prune failure must log a WARN audit line, got: {logs}"
10947        );
10948        assert!(
10949            logs.contains("locked"),
10950            "the failure cause must ride the line, got: {logs}"
10951        );
10952    }
10953
10954    #[test]
10955    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
10956        let git = GitSafety {
10957            is_main: false,
10958            removable: true,
10959            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
10960            info: vec![],
10961        };
10962        let logs = capture_info(|| {
10963            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
10964        });
10965        assert!(
10966            logs.contains("worktrees close: safety check"),
10967            "phase-1 must log a safety-check line, got: {logs}"
10968        );
10969        assert!(
10970            logs.contains("/wt/feature"),
10971            "the path must ride the line, got: {logs}"
10972        );
10973        assert!(
10974            logs.contains("window_key=\"win-42\""),
10975            "the owning window key must ride the line, got: {logs}"
10976        );
10977        assert!(logs.contains("removable=true"), "got: {logs}");
10978        assert!(logs.contains("is_main=false"), "got: {logs}");
10979        assert!(logs.contains("open=true"), "got: {logs}");
10980        assert!(
10981            logs.contains("risks=dirty,untracked"),
10982            "the blocking risk kinds must ride the line, got: {logs}"
10983        );
10984    }
10985
10986    #[test]
10987    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
10988        let git = GitSafety {
10989            is_main: false,
10990            removable: true,
10991            risks: vec![],
10992            info: vec![],
10993        };
10994        let logs = capture_info(|| {
10995            log_safety_check(Path::new("/wt/feature"), None, &git, false);
10996        });
10997        assert!(
10998            logs.contains("window_key=\"-\""),
10999            "no owning window → dash, got: {logs}"
11000        );
11001        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11002    }
11003
11004    #[test]
11005    fn log_executing_logs_the_routing_decision() {
11006        let logs = capture_info(|| {
11007            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11008        });
11009        assert!(
11010            logs.contains("worktrees close: executing"),
11011            "phase-2 must log the execute routing, got: {logs}"
11012        );
11013        assert!(
11014            logs.contains("requester=\"win-7\""),
11015            "the requester key must ride the line, got: {logs}"
11016        );
11017        assert!(logs.contains("remove=true"), "got: {logs}");
11018        assert!(logs.contains("self_close=false"), "got: {logs}");
11019        assert!(logs.contains("cross_window=3"), "got: {logs}");
11020    }
11021
11022    #[test]
11023    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11024        let logs = capture_info(|| {
11025            log_close_abort(
11026                Path::new("/wt/feature"),
11027                &anyhow!("window(s) did not close in time: win-9"),
11028            );
11029        });
11030        assert!(
11031            logs.contains("worktrees close: aborted"),
11032            "an abort must log a WARN audit line, got: {logs}"
11033        );
11034        assert!(
11035            logs.contains("/wt/feature"),
11036            "the path must ride the line, got: {logs}"
11037        );
11038        assert!(
11039            logs.contains("win-9"),
11040            "the still-open window must ride the line, got: {logs}"
11041        );
11042    }
11043
11044    #[test]
11045    fn log_window_closed_logs_the_no_removal_outcome() {
11046        let logs = capture_info(|| {
11047            log_window_closed(Path::new("/wt/feature"));
11048        });
11049        assert!(
11050            logs.contains("worktrees close: window closed, no removal"),
11051            "a remove:false close must log the no-removal outcome, got: {logs}"
11052        );
11053        assert!(
11054            logs.contains("/wt/feature"),
11055            "the path must ride the line, got: {logs}"
11056        );
11057    }
11058
11059    #[test]
11060    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11061        // The reorder (#1315) must still fully remove a worktree: both the
11062        // checked-out directory *and* the admin metadata git tracks it by, so it
11063        // no longer appears in `Repository::worktrees()`.
11064        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11065        let admin = main.path().join(".git").join("worktrees").join("feature");
11066        assert!(admin.exists(), "admin metadata should exist before removal");
11067
11068        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11069
11070        assert!(!wt_path.exists(), "the working directory should be gone");
11071        assert!(!admin.exists(), "the admin metadata should be pruned");
11072        let main_repo = Repository::open(main.path()).unwrap();
11073        assert_eq!(
11074            main_repo.worktrees().unwrap().len(),
11075            0,
11076            "git should no longer track the worktree"
11077        );
11078    }
11079
11080    #[test]
11081    fn remove_worktree_recovers_a_half_removed_orphan() {
11082        // The exact #1315 leftover: the old ordering deleted the admin metadata
11083        // first, then failed to rmdir the working tree, orphaning the directory
11084        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
11085        // rather than error with "not a git worktree".
11086        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11087        let admin = main.path().join(".git").join("worktrees").join("feature");
11088        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
11089        std::fs::remove_dir_all(&admin).unwrap();
11090        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11091        assert!(
11092            Repository::open(&wt_path).is_err(),
11093            "the orphan should not open as a repo"
11094        );
11095
11096        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11097        assert!(
11098            !wt_path.exists(),
11099            "the leftover directory should be removed"
11100        );
11101    }
11102
11103    /// A linked worktree whose working directory has been deleted out-of-band,
11104    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
11105    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
11106    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
11107    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
11108    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
11109    /// admin dir)`.
11110    fn orphaned_admin_worktree() -> (
11111        tempfile::TempDir,
11112        PathBuf,
11113        tempfile::TempDir,
11114        PathBuf,
11115        PathBuf,
11116    ) {
11117        let main_dir = tempfile::tempdir().unwrap();
11118        let main_root = main_dir.path().canonicalize().unwrap();
11119        let repo = init_repo(&main_root);
11120        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11121        repo.set_head("refs/heads/trunk").unwrap();
11122        let wt_parent = tempfile::tempdir().unwrap();
11123        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11124        add_worktree(&repo, a, &wt_path, "feature");
11125        let admin = main_root.join(".git").join("worktrees").join("feature");
11126        assert!(admin.exists(), "admin metadata exists before the orphaning");
11127        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
11128        std::fs::remove_dir_all(&wt_path).unwrap();
11129        (main_dir, main_root, wt_parent, wt_path, admin)
11130    }
11131
11132    fn window_on(folder: &Path) -> WindowEntry {
11133        WindowEntry {
11134            key: "w".to_string(),
11135            folders: vec![folder.to_path_buf()],
11136            repo: None,
11137            title: None,
11138            pid: None,
11139            last_seen: Utc::now(),
11140        }
11141    }
11142
11143    #[test]
11144    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11145        // #1403: working tree gone, admin present. An external worktree shares no
11146        // ancestor with its repo, so the owner is found via a live window on the
11147        // main repo — the same window whose repo enumeration showed the stuck row.
11148        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11149
11150        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11151
11152        assert_eq!(
11153            removed,
11154            Removal::Pruned,
11155            "the orphaned admin must be pruned"
11156        );
11157        assert!(!admin.exists(), "the admin metadata should be gone");
11158        let main_repo = Repository::open(&main_root).unwrap();
11159        assert!(
11160            main_repo.worktrees().unwrap().is_empty(),
11161            "git should no longer track the orphaned worktree"
11162        );
11163    }
11164
11165    #[test]
11166    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11167        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
11168        // worktrees/<name>` shape that produced the reported orphans) is located
11169        // by walking the gone path's ancestors — no registered window needed.
11170        let main_dir = tempfile::tempdir().unwrap();
11171        let main_root = main_dir.path().canonicalize().unwrap();
11172        let repo = init_repo(&main_root);
11173        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11174        repo.set_head("refs/heads/trunk").unwrap();
11175        // git2's `worktree()` creates the leaf but not intermediate parents.
11176        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11177        let wt_path = main_root.join(".nested").join("feature-wt");
11178        add_worktree(&repo, a, &wt_path, "feature");
11179        let admin = main_root.join(".git").join("worktrees").join("feature");
11180        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
11181
11182        let removed = remove_worktree(&wt_path, &[]).unwrap();
11183
11184        assert_eq!(removed, Removal::Pruned);
11185        assert!(!admin.exists(), "the admin metadata should be gone");
11186    }
11187
11188    #[test]
11189    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
11190        // The other side of #1403: working tree gone AND admin already pruned. No
11191        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
11192        // never a `pruned` lie.
11193        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11194        // Prune the admin entry too, so nothing remains to remove.
11195        std::fs::remove_dir_all(&admin).unwrap();
11196
11197        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11198
11199        assert_eq!(removed, Removal::AlreadyGone);
11200    }
11201
11202    #[test]
11203    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
11204        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
11205        // External worktree: no ancestor is the repo, so only the window feed finds
11206        // it. The main root rides through, deduped to a single entry.
11207        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
11208        assert!(
11209            roots.contains(&main_root),
11210            "the owning main repo must be a candidate, got: {roots:?}"
11211        );
11212    }
11213
11214    #[test]
11215    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
11216        // A candidate root that does not open as a repo (a stale registry folder,
11217        // a deleted repo) is skipped rather than fatal; the real owner that
11218        // follows still prunes.
11219        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11220        let junk = tempfile::tempdir().unwrap();
11221
11222        let removed =
11223            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
11224
11225        assert_eq!(removed, Removal::Pruned);
11226        assert!(
11227            !admin.exists(),
11228            "the real owner must still prune the orphan"
11229        );
11230    }
11231
11232    #[test]
11233    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
11234        // A candidate that opens as a repo but is a *linked* worktree carries no
11235        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
11236        // is the real owner. (A live sibling worktree is exactly such a candidate.)
11237        let main_dir = tempfile::tempdir().unwrap();
11238        let main_root = main_dir.path().canonicalize().unwrap();
11239        let repo = init_repo(&main_root);
11240        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11241        repo.set_head("refs/heads/trunk").unwrap();
11242        let wt_parent = tempfile::tempdir().unwrap();
11243        let wt_root = wt_parent.path().canonicalize().unwrap();
11244        let orphan = wt_root.join("orphan-wt");
11245        let sibling = wt_root.join("sibling-wt");
11246        add_worktree(&repo, a, &orphan, "orphan");
11247        add_worktree(&repo, a, &sibling, "sibling");
11248        let admin = main_root.join(".git").join("worktrees").join("orphan");
11249        std::fs::remove_dir_all(&orphan).unwrap();
11250
11251        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
11252        // main repo second (the actual owner).
11253        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
11254
11255        assert_eq!(removed, Removal::Pruned);
11256        assert!(
11257            !admin.exists(),
11258            "the orphan's admin metadata must be pruned"
11259        );
11260    }
11261
11262    #[test]
11263    fn prune_orphaned_admin_refuses_a_locked_orphan() {
11264        // Locking is an admin-dir file, independent of the (gone) checkout, so an
11265        // orphaned worktree can still be locked. The prune must refuse it —
11266        // "unlock first" — rather than force past, mirroring the live path.
11267        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11268        let main_repo = Repository::open(&main_root).unwrap();
11269        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
11270        main_repo
11271            .find_worktree(&name)
11272            .unwrap()
11273            .lock(Some("in use"))
11274            .unwrap();
11275
11276        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
11277
11278        assert!(
11279            err.to_string().contains("locked"),
11280            "a locked orphan must be refused, got: {err:#}"
11281        );
11282        assert!(admin.exists(), "a refused prune must leave the admin entry");
11283    }
11284
11285    #[test]
11286    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
11287        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11288        // A live worktree: gitlink resolves → not an orphan.
11289        assert!(!is_orphaned_worktree(&wt_path));
11290        // The main checkout has a `.git` directory → not an orphan.
11291        assert!(!is_orphaned_worktree(main.path()));
11292        // Drop the admin metadata → the gitlink now dangles → orphan.
11293        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
11294            .unwrap();
11295        assert!(is_orphaned_worktree(&wt_path));
11296    }
11297
11298    #[test]
11299    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
11300        let tmp = tempfile::tempdir().unwrap();
11301        let missing = tmp.path().join("gone");
11302        assert!(remove_dir_all_retrying(&missing).is_ok());
11303    }
11304
11305    #[test]
11306    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
11307        use std::io::Error;
11308        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
11309            assert!(
11310                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11311                "errno {errno} is the concurrent-writer race and must be retried"
11312            );
11313        }
11314        // A hard failure must surface immediately rather than burn the backoff
11315        // waiting for a condition that will never clear.
11316        for errno in [
11317            nix::libc::EACCES,
11318            nix::libc::EPERM,
11319            nix::libc::EROFS,
11320            nix::libc::ENOTDIR,
11321        ] {
11322            assert!(
11323                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
11324                "errno {errno} is permanent and must not be retried"
11325            );
11326        }
11327        // Not from the OS at all, so there is no errno to classify.
11328        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
11329    }
11330
11331    #[test]
11332    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
11333        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
11334        // race, so it must fail on the first attempt with the original cause
11335        // attached, leaving the path untouched.
11336        let tmp = tempfile::tempdir().unwrap();
11337        let file = tmp.path().join("not-a-directory");
11338        std::fs::write(&file, b"x").unwrap();
11339
11340        let mut attempts = 0;
11341        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
11342            attempts += 1;
11343            std::fs::remove_dir_all(&file)
11344        })
11345        .unwrap_err();
11346
11347        assert_eq!(attempts, 1, "a permanent error must not be retried");
11348        assert!(
11349            err.to_string()
11350                .contains("failed to remove worktree directory"),
11351            "unexpected error: {err:#}"
11352        );
11353        assert!(err.source().is_some(), "the io::Error cause is preserved");
11354        assert!(file.exists());
11355    }
11356
11357    #[test]
11358    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
11359        // A writer that never quiesces: every sweep re-finds the directory
11360        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
11361        // than the loop spinning forever.
11362        let tmp = tempfile::tempdir().unwrap();
11363        let mut attempts = 0;
11364        let backoff = [Duration::ZERO, Duration::ZERO];
11365        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
11366            attempts += 1;
11367            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11368        })
11369        .unwrap_err();
11370
11371        // One attempt per delay, plus the initial one.
11372        assert_eq!(attempts, backoff.len() + 1);
11373        assert!(
11374            err.to_string()
11375                .contains("failed to remove worktree directory"),
11376            "unexpected error: {err:#}"
11377        );
11378    }
11379
11380    #[test]
11381    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
11382        // The #1315 happy path, deterministically: the race clears partway through
11383        // the schedule and the removal then succeeds.
11384        let tmp = tempfile::tempdir().unwrap();
11385        let mut attempts = 0;
11386        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
11387            attempts += 1;
11388            if attempts < 3 {
11389                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
11390            } else {
11391                Ok(())
11392            }
11393        });
11394        assert!(result.is_ok(), "{result:?}");
11395        assert_eq!(attempts, 3);
11396    }
11397
11398    #[test]
11399    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
11400        // A `.git` file that is readable but carries no `gitdir:` pointer is not
11401        // something we may delete.
11402        let tmp = tempfile::tempdir().unwrap();
11403        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
11404        assert!(!is_orphaned_worktree(tmp.path()));
11405    }
11406
11407    #[test]
11408    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
11409        // Neither a repo nor an orphan: refuse it rather than recursively deleting
11410        // whatever directory was passed in.
11411        let tmp = tempfile::tempdir().unwrap();
11412        let plain = tmp.path().join("plain");
11413        std::fs::create_dir(&plain).unwrap();
11414
11415        let err = remove_worktree(&plain, &[]).unwrap_err();
11416
11417        assert!(
11418            err.to_string().contains("not a git worktree"),
11419            "unexpected error: {err:#}"
11420        );
11421        assert!(plain.exists(), "a non-worktree path must be left alone");
11422    }
11423
11424    #[test]
11425    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
11426        // Acceptance criterion (#1315): a language server / cargo still writing
11427        // into `target/` as the window closes makes the recursive rmdir race with
11428        // "Directory not empty". A background thread reproduces that by
11429        // repopulating `target/` for a bounded window; removal must retry past it
11430        // and still succeed once the writer stops.
11431        use std::sync::atomic::{AtomicBool, Ordering};
11432        use std::sync::Arc;
11433
11434        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11435        // Created once, here — never inside the writer loop. `create_dir_all`
11436        // rebuilds every *parent* component, so calling it per iteration let the
11437        // writer resurrect the worktree root the instant removal won the race,
11438        // failing the final assertion on a directory removal had correctly
11439        // deleted and the test itself put back (#1410).
11440        let nested = wt_path.join("target").join("nested");
11441        std::fs::create_dir_all(&nested).unwrap();
11442
11443        let stop = Arc::new(AtomicBool::new(false));
11444        let writer_stop = Arc::clone(&stop);
11445        let writer_dir = nested;
11446        let writer = std::thread::spawn(move || {
11447            let mut n = 0u64;
11448            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
11449            // stop so a later removal pass finds the directory quiescent.
11450            let deadline = std::time::Instant::now() + Duration::from_millis(400);
11451            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
11452                // Best-effort, and deliberately creating no directory: `fs::write`
11453                // is `File::create`, which never makes parents. While `target/`
11454                // survives these keep it non-empty — the ENOTEMPTY removal has to
11455                // retry past — and once removal wins they simply fail with ENOENT.
11456                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
11457                n += 1;
11458            }
11459        });
11460
11461        let result = remove_worktree(&wt_path, &[]);
11462        stop.store(true, Ordering::Relaxed);
11463        writer.join().unwrap();
11464
11465        assert!(
11466            result.is_ok(),
11467            "removal should retry past the writer: {result:?}"
11468        );
11469        assert!(!wt_path.exists(), "the worktree directory should be gone");
11470    }
11471
11472    #[tokio::test]
11473    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
11474        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11475        // An untracked file in the worktree would be lost on removal.
11476        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
11477
11478        let svc = WorktreesService::new();
11479        let report = svc
11480            .handle("close", json!({ "path": wt_path, "remove": true }))
11481            .await
11482            .unwrap();
11483        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11484        assert!(
11485            risks
11486                .iter()
11487                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
11488            "expected an untracked risk: {report}"
11489        );
11490        // Still removable — the risk only means "confirm first", not "refuse".
11491        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
11492        // The unconfirmed check has no side effects.
11493        assert!(wt_path.exists());
11494    }
11495
11496    #[tokio::test]
11497    async fn close_confirmed_removes_a_dirty_worktree() {
11498        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11499        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
11500        let svc = WorktreesService::new();
11501        // With confirmation, the risks are overridden and removal proceeds.
11502        let reply = svc
11503            .handle(
11504                "close",
11505                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11506            )
11507            .await
11508            .unwrap();
11509        assert_eq!(reply, json!({ "removed": true }));
11510        assert!(!wt_path.exists());
11511    }
11512
11513    #[tokio::test]
11514    async fn close_refuses_to_remove_the_main_working_tree() {
11515        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11516        let svc = WorktreesService::new();
11517        // Phase 1: the main tree reports not-removable, marked main.
11518        let report = svc
11519            .handle("close", json!({ "path": main.path(), "remove": true }))
11520            .await
11521            .unwrap();
11522        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
11523        assert_eq!(
11524            report.get("removable").and_then(Value::as_bool),
11525            Some(false)
11526        );
11527        // Phase 2: even a confirmed delete of the main tree is refused
11528        // defensively, and the directory is untouched.
11529        assert!(svc
11530            .handle(
11531                "close",
11532                json!({ "path": main.path(), "remove": true, "confirmed": true }),
11533            )
11534            .await
11535            .is_err());
11536        assert!(main.path().exists());
11537    }
11538
11539    #[tokio::test]
11540    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
11541        // The case a naive impl would wrongly protect: a linked worktree checked
11542        // out on `main` (the default branch) is *still a linked worktree*, so it
11543        // is fully deletable — and `main` survives (removal never deletes a branch).
11544        let main_dir = tempfile::tempdir().unwrap();
11545        let repo = init_repo(main_dir.path());
11546        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11547        repo.set_head("refs/heads/trunk").unwrap();
11548        let wt_parent = tempfile::tempdir().unwrap();
11549        let wt_path = wt_parent.path().join("main-wt");
11550        add_worktree(&repo, a, &wt_path, "main");
11551
11552        let svc = WorktreesService::new();
11553        let reply = svc
11554            .handle(
11555                "close",
11556                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11557            )
11558            .await
11559            .unwrap();
11560        assert_eq!(reply, json!({ "removed": true }));
11561        assert!(!wt_path.exists());
11562        // The `main` branch is untouched by the worktree removal.
11563        assert!(
11564            repo.find_branch("main", git2::BranchType::Local).is_ok(),
11565            "the default branch must survive worktree removal"
11566        );
11567    }
11568
11569    #[tokio::test]
11570    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
11571        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11572        let svc = WorktreesService::new();
11573        // First removal succeeds.
11574        svc.handle(
11575            "close",
11576            json!({ "path": wt_path, "remove": true, "confirmed": true }),
11577        )
11578        .await
11579        .unwrap();
11580        // A second confirmed close of the now-missing path is a clean success,
11581        // not an error (a stale snapshot must not crash).
11582        let reply = svc
11583            .handle(
11584                "close",
11585                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11586            )
11587            .await
11588            .unwrap();
11589        assert_eq!(reply, json!({ "removed": true }));
11590    }
11591
11592    #[tokio::test]
11593    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
11594        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
11595        // behind so the tree view keeps showing a `prunable` row. Closing it must
11596        // actually prune the admin metadata (via the registered window's repo), not
11597        // report a `pruned` no-op that leaves the row stuck.
11598        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11599        let svc = WorktreesService::new();
11600        // A live window on the main repo — the vantage point the stuck row is
11601        // enumerated from, and the one the prune locates the owner through.
11602        svc.handle(
11603            "register",
11604            register_payload("main-w", None, &main_root.display().to_string()),
11605        )
11606        .await
11607        .unwrap();
11608
11609        // Before: the orphaned linked worktree still shows alongside the main tree.
11610        let before = svc.handle("tree", Value::Null).await.unwrap();
11611        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
11612        assert_eq!(
11613            worktrees_before, 2,
11614            "the orphaned row is present before close"
11615        );
11616
11617        let reply = svc
11618            .handle(
11619                "close",
11620                json!({ "path": wt_path, "remove": true, "confirmed": true }),
11621            )
11622            .await
11623            .unwrap();
11624        assert_eq!(reply, json!({ "removed": true }));
11625
11626        // After: admin metadata pruned and the row is gone from the tree view.
11627        assert!(!admin.exists(), "the admin metadata must be pruned");
11628        let after = svc.handle("tree", Value::Null).await.unwrap();
11629        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
11630        assert_eq!(worktrees_after, 1, "only the main working tree remains");
11631    }
11632
11633    #[tokio::test]
11634    async fn close_safety_check_detects_detached_head_unreachable_commits() {
11635        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11636        // In the worktree, commit onto a detached HEAD so the new commit is
11637        // reachable from no ref — it would be GC'd on removal.
11638        let wt_repo = Repository::open(&wt_path).unwrap();
11639        let parent_oid = wt_repo.head().unwrap().target().unwrap();
11640        let parent = wt_repo.find_commit(parent_oid).unwrap();
11641        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
11642        wt_repo.set_head_detached(orphan).unwrap();
11643
11644        let svc = WorktreesService::new();
11645        let report = svc
11646            .handle("close", json!({ "path": wt_path, "remove": true }))
11647            .await
11648            .unwrap();
11649        let risks = report.get("risks").and_then(Value::as_array).unwrap();
11650        assert!(
11651            risks
11652                .iter()
11653                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
11654            "expected an unreachable-commits risk: {report}"
11655        );
11656    }
11657
11658    #[tokio::test]
11659    async fn close_self_close_removes_when_the_requester_owns_the_target() {
11660        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11661        let svc = WorktreesService::new();
11662        // The requesting window itself has the worktree open: it is the only
11663        // owning window, so there is nothing to wait on — remove and reply, and
11664        // the extension closes its own window on `ok`.
11665        svc.handle(
11666            "register",
11667            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
11668        )
11669        .await
11670        .unwrap();
11671        let reply = svc
11672            .handle(
11673                "close",
11674                json!({
11675                    "path": wt_path,
11676                    "remove": true,
11677                    "confirmed": true,
11678                    "requester_key": "w1",
11679                }),
11680            )
11681            .await
11682            .unwrap();
11683        assert_eq!(reply, json!({ "removed": true }));
11684        assert!(!wt_path.exists());
11685    }
11686
11687    #[tokio::test]
11688    async fn close_safety_check_surfaces_the_owning_window() {
11689        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11690        let svc = WorktreesService::new();
11691        // A multi-root window owns the target: the report surfaces its key and
11692        // folder count so the extension can warn "all N folders will close".
11693        svc.handle(
11694            "register",
11695            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
11696        )
11697        .await
11698        .unwrap();
11699        let report = svc
11700            .handle("close", json!({ "path": wt_path, "remove": true }))
11701            .await
11702            .unwrap();
11703        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
11704        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
11705        assert_eq!(
11706            report.get("window_folder_count").and_then(Value::as_u64),
11707            Some(2)
11708        );
11709    }
11710
11711    #[tokio::test]
11712    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
11713        let svc = WorktreesService::new();
11714        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11715            .await
11716            .unwrap();
11717        // No directive → a plain `{ known: true }`, byte-identical to before.
11718        assert_eq!(
11719            svc.handle("heartbeat", json!({ "key": "w1" }))
11720                .await
11721                .unwrap(),
11722            json!({ "known": true })
11723        );
11724        // Marked → the next heartbeat carries `close: true`, exactly once.
11725        svc.registry.mark_close_pending("w1");
11726        assert_eq!(
11727            svc.handle("heartbeat", json!({ "key": "w1" }))
11728                .await
11729                .unwrap(),
11730            json!({ "known": true, "close": true })
11731        );
11732        assert_eq!(
11733            svc.handle("heartbeat", json!({ "key": "w1" }))
11734                .await
11735                .unwrap(),
11736            json!({ "known": true })
11737        );
11738    }
11739
11740    // --- Reload op (#1417) -------------------------------------------------
11741
11742    #[tokio::test]
11743    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
11744        let svc = WorktreesService::new();
11745        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11746            .await
11747            .unwrap();
11748        // Nothing pending → `reload` is absent, so a companion that predates
11749        // #1417 sees a byte-identical reply.
11750        assert_eq!(
11751            svc.handle("heartbeat", json!({ "key": "w1" }))
11752                .await
11753                .unwrap(),
11754            json!({ "known": true })
11755        );
11756        // Marked → the next heartbeat carries `reload: true`, exactly once.
11757        svc.registry.mark_reload_pending("w1");
11758        assert_eq!(
11759            svc.handle("heartbeat", json!({ "key": "w1" }))
11760                .await
11761                .unwrap(),
11762            json!({ "known": true, "reload": true })
11763        );
11764        assert_eq!(
11765            svc.handle("heartbeat", json!({ "key": "w1" }))
11766                .await
11767                .unwrap(),
11768            json!({ "known": true })
11769        );
11770    }
11771
11772    #[tokio::test]
11773    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
11774        let svc = WorktreesService::new();
11775        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11776            .await
11777            .unwrap();
11778        // Independent `if`s, not an `else`: both fields ride the same reply and
11779        // both are consumed, so neither directive can be stranded by the other.
11780        // The companion resolves the collision by checking `close` first.
11781        svc.registry.mark_close_pending("w1");
11782        svc.registry.mark_reload_pending("w1");
11783        assert_eq!(
11784            svc.handle("heartbeat", json!({ "key": "w1" }))
11785                .await
11786                .unwrap(),
11787            json!({ "known": true, "close": true, "reload": true })
11788        );
11789        assert_eq!(
11790            svc.handle("heartbeat", json!({ "key": "w1" }))
11791                .await
11792                .unwrap(),
11793            json!({ "known": true })
11794        );
11795    }
11796
11797    #[tokio::test]
11798    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
11799        let svc = WorktreesService::new();
11800        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11801            .await
11802            .unwrap();
11803        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11804            .await
11805            .unwrap();
11806
11807        // A key with no live window is reported, never an error: a window
11808        // closing between the client listing and sending is routine.
11809        let reply = svc
11810            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
11811            .await
11812            .unwrap();
11813        assert_eq!(
11814            reply,
11815            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
11816        );
11817
11818        // Both live targets now have a directive waiting; the unknown one does
11819        // not (the daemon must not resurrect a key it never knew).
11820        assert!(svc.registry.take_reload_pending("w1"));
11821        assert!(svc.registry.take_reload_pending("w2"));
11822        assert!(!svc.registry.take_reload_pending("ghost"));
11823    }
11824
11825    #[tokio::test]
11826    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
11827        let svc = WorktreesService::new();
11828        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11829            .await
11830            .unwrap();
11831
11832        // A client repeating a key asks for one reload, not two — `requested`
11833        // counts distinct targets so the client's summary cannot overstate.
11834        assert_eq!(
11835            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
11836                .await
11837                .unwrap(),
11838            json!({ "requested": 1, "signalled": 1, "unknown": [] })
11839        );
11840
11841        // An empty batch is a no-op success, and a missing field is an empty
11842        // batch — the callers filter their targets before sending.
11843        assert_eq!(
11844            svc.handle("reload", json!({ "target_keys": [] }))
11845                .await
11846                .unwrap(),
11847            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11848        );
11849        assert_eq!(
11850            svc.handle("reload", json!({})).await.unwrap(),
11851            json!({ "requested": 0, "signalled": 0, "unknown": [] })
11852        );
11853    }
11854
11855    #[tokio::test]
11856    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
11857        let svc = WorktreesService::new();
11858        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11859            .await
11860            .unwrap();
11861        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
11862            .await
11863            .unwrap();
11864
11865        // The end-to-end contract: `reload` marks, the target's own heartbeat
11866        // delivers. Unlike `close`, nothing waits — the op has already returned.
11867        svc.handle("reload", json!({ "target_keys": ["w2"] }))
11868            .await
11869            .unwrap();
11870        assert_eq!(
11871            svc.handle("heartbeat", json!({ "key": "w2" }))
11872                .await
11873                .unwrap(),
11874            json!({ "known": true, "reload": true })
11875        );
11876        // A window that was not a target is untouched.
11877        assert_eq!(
11878            svc.handle("heartbeat", json!({ "key": "w1" }))
11879                .await
11880                .unwrap(),
11881            json!({ "known": true })
11882        );
11883    }
11884
11885    #[tokio::test]
11886    async fn reload_op_treats_an_unregistered_window_as_unknown() {
11887        let svc = WorktreesService::new();
11888        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
11889            .await
11890            .unwrap();
11891        svc.handle("unregister", json!({ "key": "w1" }))
11892            .await
11893            .unwrap();
11894        // `list()` reaps on read, so a window that has gone away cannot be
11895        // signalled — it is reported instead.
11896        assert_eq!(
11897            svc.handle("reload", json!({ "target_keys": ["w1"] }))
11898                .await
11899                .unwrap(),
11900            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
11901        );
11902    }
11903
11904    #[tokio::test]
11905    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
11906        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11907        let svc = Arc::new(WorktreesService::new());
11908        // A *different* window (not the requester) owns the target.
11909        svc.handle(
11910            "register",
11911            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11912        )
11913        .await
11914        .unwrap();
11915
11916        // Drive the destructive close concurrently: it marks w2 to close and
11917        // waits for it to unregister before removing.
11918        let svc2 = svc.clone();
11919        let path = wt_path.clone();
11920        let close = tokio::spawn(async move {
11921            svc2.handle(
11922                "close",
11923                json!({
11924                    "path": path,
11925                    "remove": true,
11926                    "confirmed": true,
11927                    "requester_key": "w1",
11928                }),
11929            )
11930            .await
11931        });
11932
11933        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
11934        // closes its window and unregisters. Poll until the directive appears.
11935        let mut saw_close = false;
11936        for _ in 0..200 {
11937            let hb = svc
11938                .handle("heartbeat", json!({ "key": "w2" }))
11939                .await
11940                .unwrap();
11941            if hb.get("close").and_then(Value::as_bool) == Some(true) {
11942                saw_close = true;
11943                svc.handle("unregister", json!({ "key": "w2" }))
11944                    .await
11945                    .unwrap();
11946                break;
11947            }
11948            tokio::time::sleep(Duration::from_millis(5)).await;
11949        }
11950        assert!(saw_close, "w2 should have been told to close");
11951
11952        // Once w2 has unregistered, the close op removes the worktree.
11953        let reply = close.await.unwrap().unwrap();
11954        assert_eq!(reply, json!({ "removed": true }));
11955        assert!(!wt_path.exists());
11956    }
11957
11958    #[tokio::test]
11959    async fn await_windows_closed_times_out_when_a_window_never_closes() {
11960        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
11961        let svc = WorktreesService::new();
11962        svc.handle(
11963            "register",
11964            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
11965        )
11966        .await
11967        .unwrap();
11968        // The owning window never unregisters: the wait gives up (with a short
11969        // timeout here) rather than block, and names the still-open window.
11970        let err = await_windows_closed(
11971            &svc.registry,
11972            &wt_path,
11973            Some("w1"),
11974            Duration::from_millis(150),
11975            Duration::from_millis(25),
11976        )
11977        .await
11978        .unwrap_err();
11979        assert!(
11980            err.to_string().contains("w2"),
11981            "error names the window: {err}"
11982        );
11983        // The requester itself is excluded, so a self-only owner returns at once.
11984        await_windows_closed(
11985            &svc.registry,
11986            &wt_path,
11987            Some("w2"),
11988            Duration::from_millis(150),
11989            Duration::from_millis(25),
11990        )
11991        .await
11992        .unwrap();
11993    }
11994
11995    #[tokio::test]
11996    async fn close_window_without_remove_replies_closed_and_never_deletes() {
11997        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
11998        let svc = WorktreesService::new();
11999        // "Close Window" on the main tree: no git inspection, no removal.
12000        let reply = svc
12001            .handle("close", json!({ "path": main.path(), "remove": false }))
12002            .await
12003            .unwrap();
12004        assert_eq!(reply, json!({ "closed": true }));
12005        assert!(main.path().exists());
12006    }
12007
12008    #[tokio::test]
12009    async fn close_safety_check_flags_modified_tracked_files() {
12010        // A tracked file, checked out into the linked worktree, then modified —
12011        // its content is lost on removal, so it is a `dirty` risk (distinct from
12012        // the untracked case).
12013        let main_dir = tempfile::tempdir().unwrap();
12014        let repo = init_repo(main_dir.path());
12015        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
12016        repo.set_head("refs/heads/trunk").unwrap();
12017        let wt_parent = tempfile::tempdir().unwrap();
12018        let wt_path = wt_parent.path().join("feature-wt");
12019        add_worktree(&repo, a, &wt_path, "feature");
12020        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
12021
12022        let svc = WorktreesService::new();
12023        let report = svc
12024            .handle("close", json!({ "path": wt_path, "remove": true }))
12025            .await
12026            .unwrap();
12027        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12028        assert!(
12029            risks
12030                .iter()
12031                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
12032            "expected a dirty risk: {report}"
12033        );
12034    }
12035
12036    #[tokio::test]
12037    async fn close_safety_check_flags_an_in_progress_operation() {
12038        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
12039        // non-Clean (interrupted merge) state — its progress is lost on removal.
12040        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12041        let wt_repo = Repository::open(&wt_path).unwrap();
12042        let head = wt_repo.head().unwrap().target().unwrap();
12043        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
12044        assert_ne!(wt_repo.state(), RepositoryState::Clean);
12045
12046        let svc = WorktreesService::new();
12047        let report = svc
12048            .handle("close", json!({ "path": wt_path, "remove": true }))
12049            .await
12050            .unwrap();
12051        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12052        assert!(
12053            risks
12054                .iter()
12055                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
12056            "expected an in-progress risk: {report}"
12057        );
12058    }
12059
12060    #[tokio::test]
12061    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
12062        // A linked worktree on `feature`, which tracks `origin/feature` and is one
12063        // commit ahead. The unpushed commit is INFO (the branch — and thus the
12064        // commit — survives removal), never a blocking risk.
12065        let main_dir = tempfile::tempdir().unwrap();
12066        let repo = init_repo(main_dir.path());
12067        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12068        repo.set_head("refs/heads/trunk").unwrap();
12069        let a_commit = repo.find_commit(a).unwrap();
12070        repo.branch("feature", &a_commit, false).unwrap();
12071        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
12072            .unwrap();
12073        // `feature` advances one commit past `origin/feature`.
12074        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
12075        drop(a_commit);
12076        let mut cfg = repo.config().unwrap();
12077        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
12078            .unwrap();
12079        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
12080            .unwrap();
12081        cfg.set_str("branch.feature.remote", "origin").unwrap();
12082        cfg.set_str("branch.feature.merge", "refs/heads/feature")
12083            .unwrap();
12084        // A worktree on the existing `feature` branch (not created fresh, so it
12085        // keeps the ahead-of-upstream divergence).
12086        let wt_parent = tempfile::tempdir().unwrap();
12087        let wt_path = wt_parent.path().join("feature-wt");
12088        let reference = repo.find_reference("refs/heads/feature").unwrap();
12089        let mut opts = git2::WorktreeAddOptions::new();
12090        opts.reference(Some(&reference));
12091        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
12092
12093        let svc = WorktreesService::new();
12094        let report = svc
12095            .handle("close", json!({ "path": wt_path, "remove": true }))
12096            .await
12097            .unwrap();
12098        // Unpushed commits appear as `info`, and the worktree is still cleanly
12099        // removable with no blocking risks.
12100        let info = report.get("info").and_then(Value::as_array).unwrap();
12101        assert!(
12102            info.iter()
12103                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
12104            "expected an unpushed info note: {report}"
12105        );
12106        assert!(
12107            report
12108                .get("risks")
12109                .and_then(Value::as_array)
12110                .unwrap()
12111                .is_empty(),
12112            "unpushed commits alone must not block: {report}"
12113        );
12114        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12115    }
12116
12117    #[tokio::test]
12118    async fn close_safety_check_ignores_gitignored_files() {
12119        // With `.gitignore` committed, an ignored artifact is the only worktree
12120        // change — it must not count as untracked (it is regenerable), so the
12121        // worktree stays cleanly removable with no risks.
12122        let main_dir = tempfile::tempdir().unwrap();
12123        let repo = init_repo(main_dir.path());
12124        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
12125        repo.set_head("refs/heads/trunk").unwrap();
12126        let wt_parent = tempfile::tempdir().unwrap();
12127        let wt_path = wt_parent.path().join("feature-wt");
12128        add_worktree(&repo, a, &wt_path, "feature");
12129        std::fs::create_dir(wt_path.join("build")).unwrap();
12130        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
12131
12132        let svc = WorktreesService::new();
12133        let report = svc
12134            .handle("close", json!({ "path": wt_path, "remove": true }))
12135            .await
12136            .unwrap();
12137        assert!(
12138            report
12139                .get("risks")
12140                .and_then(Value::as_array)
12141                .unwrap()
12142                .is_empty(),
12143            "a gitignored file must not create a risk: {report}"
12144        );
12145        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12146    }
12147
12148    #[tokio::test]
12149    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
12150        // The phase-1 check on a path that no longer exists reports it removable
12151        // with no risks (so the idempotent execute proceeds with no dialog).
12152        let svc = WorktreesService::new();
12153        let report = svc
12154            .handle(
12155                "close",
12156                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
12157            )
12158            .await
12159            .unwrap();
12160        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12161        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
12162        assert!(report
12163            .get("risks")
12164            .and_then(Value::as_array)
12165            .unwrap()
12166            .is_empty());
12167        let info = report.get("info").and_then(Value::as_array).unwrap();
12168        assert!(info
12169            .iter()
12170            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
12171    }
12172
12173    #[tokio::test]
12174    async fn close_phase1_errors_on_a_non_git_worktree_path() {
12175        // An existing directory that is *not* a git worktree makes `git_safety`
12176        // fail; the error must propagate (rather than delete an unknown dir),
12177        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
12178        // audit line itself is unit-tested via `log_close_error`.
12179        let dir = tempfile::tempdir().unwrap();
12180        let svc = WorktreesService::new();
12181        let result = svc
12182            .handle("close", json!({ "path": dir.path(), "remove": true }))
12183            .await;
12184        assert!(
12185            result.is_err(),
12186            "a non-git-worktree target must error the safety check, got: {result:?}"
12187        );
12188    }
12189
12190    #[tokio::test]
12191    async fn close_refuses_a_locked_worktree() {
12192        // A locked worktree (git worktree lock) must be refused, not forced past
12193        // (failure mode #6), and left on disk.
12194        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12195        let main_repo = Repository::open(main.path()).unwrap();
12196        main_repo
12197            .find_worktree("feature")
12198            .unwrap()
12199            .lock(Some("under test"))
12200            .unwrap();
12201
12202        let svc = WorktreesService::new();
12203        let err = svc
12204            .handle(
12205                "close",
12206                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12207            )
12208            .await
12209            .unwrap_err();
12210        assert!(
12211            err.to_string().contains("locked"),
12212            "expected a locked error: {err}"
12213        );
12214        assert!(wt_path.exists(), "a locked worktree must not be removed");
12215    }
12216
12217    #[tokio::test]
12218    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
12219        // A detached HEAD that still sits on a commit a branch points to loses
12220        // nothing on removal, so it must NOT produce an unreachable-commits risk
12221        // (the false-positive the reachability walk guards against).
12222        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12223        let wt_repo = Repository::open(&wt_path).unwrap();
12224        // The worktree is on `feature`; detach HEAD onto its current tip, which
12225        // the `feature` branch still references.
12226        let tip = wt_repo.head().unwrap().target().unwrap();
12227        wt_repo.set_head_detached(tip).unwrap();
12228        assert!(wt_repo.head_detached().unwrap());
12229
12230        let svc = WorktreesService::new();
12231        let report = svc
12232            .handle("close", json!({ "path": wt_path, "remove": true }))
12233            .await
12234            .unwrap();
12235        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12236        assert!(
12237            !risks
12238                .iter()
12239                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12240            "a detached HEAD reachable from a branch must not be flagged: {report}"
12241        );
12242    }
12243
12244    #[test]
12245    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
12246        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12247        let main_repo = Repository::open(main.path()).unwrap();
12248        // The real linked worktree resolves to its registered name.
12249        assert_eq!(
12250            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
12251            "feature"
12252        );
12253        // A path that is not one of this repo's worktrees is the defensive
12254        // "not registered" error (the guard behind removal).
12255        let err =
12256            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
12257        assert!(
12258            err.to_string().contains("not registered"),
12259            "expected a not-registered error: {err}"
12260        );
12261    }
12262
12263    #[test]
12264    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
12265        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
12266        // rather than sinking the whole safety check.
12267        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12268        let repo = Repository::open(&wt_path).unwrap();
12269        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
12270        // Confirm the corruption actually breaks status enumeration, so the
12271        // count is exercising the error-degradation branch (not an empty repo).
12272        assert!(
12273            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
12274            "a corrupt index should make statuses() fail"
12275        );
12276        assert_eq!(count_dirty_untracked(&repo), (0, 0));
12277    }
12278}